diff --git a/CHANGELOG.md b/CHANGELOG.md
index a77acac5..c70e2cff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,98 @@
# Changelog
+## 7.7.0
+
+Three things that were true only in one place become true everywhere: the file
+browser now works on remote projects, a remote run finds the same `node` an
+interactive shell would, and the dials a new chat opens on are yours to set.
+
+### New-chat defaults
+
+- **Five dials, set once.** Mode, agent mode, model, thinking effort and turn
+ budget now have a configured default instead of a literal buried in the markup.
+ Settings owns the global half; a project can override any subset of the five.
+ The chain is **project override → global default → built-in**, every row shows
+ which of the three answered, and each has a one-click Reset.
+
+- **A project stores only the dials it pinned.** Storing all five instead would
+ freeze each project at whatever the global happened to be the day it was
+ created — the opposite of what a default is for. Pin the model, change the
+ global turn budget next month, and the project follows.
+
+- **The chain reaches the server, not just the toolbar.** Both doors to a new
+ interactive chat — the WebSocket `chat` frame and `POST /api/sessions` — used
+ to carry their own literal set (`sonnet` / `auto` / `single` / 30 turns). An
+ API client or a browser running a cached older build therefore created chats on
+ values nobody had configured, while the toolbar on screen showed the resolved
+ ones. Both now resolve the same chain, and the resolved row is computed before
+ the session is inserted so the stored row and the run cannot disagree.
+
+- **Telegram and the scheduler deliberately do not inherit it.** They keep their
+ own 30-turn budget, now under a name (`UNATTENDED_MAX_TURNS`) rather than
+ copy-pasted at eleven call sites. A scheduled job that silently inherited
+ someone's `turns: 200` would spend a budget nobody was watching, and every
+ existing install would have jumped from 30 to 50 on upgrade without being
+ asked. Per-channel budgets, if they are ever wanted, belong in their own
+ setting — not in the dials that describe a chat window.
+
+- **An empty turn-budget box no longer means 30.** The value the browser actually
+ put on the wire fell back to a literal that matched neither the box's own
+ placeholder nor the server's built-in. It now falls back to your configured
+ default.
+
+### Remote projects get a file browser
+
+- **List a directory, open a file — over SSH.** The file tree, the file viewer
+ and `@`-mention search work on remote projects, which previously answered
+ "file browser not available". That refusal was correct while there was no
+ remote read path: the browser reads the local disk, so pointing it at
+ `/home/user/project` from a Windows client resolved to `C:\home\user\project`.
+
+- **Read-only by design.** Download, share, copy-image and image/PDF preview stay
+ hidden for a remote file rather than failing under your finger, and come back
+ the moment you open a local one.
+
+- **Caps are announced, never silent.** Listings stop at 2000 entries and files
+ at 2 MB, both enforced on the remote host — an 8 GB log never crosses the link
+ — and both stated in the UI instead of quietly truncating.
+
+- **Three guard layers, none of them the local one.** Paths resolve with POSIX
+ semantics whatever this server runs on; the remote script re-checks containment
+ against the physical path (`pwd -P`), because a symlink inside the project
+ satisfies a textual check by construction; and every row whose path escapes the
+ base the remote echoed back is dropped. A symlink whose final component is a
+ file is refused rather than followed. Filenames are attacker-controlled in the
+ way that matters — a repo you cloned can contain one with a newline in it — so
+ every control line is framed by a per-request random nonce.
+
+### Remote runs find the tools that are actually installed
+
+- **`bash -lc` is a login shell, not an interactive one.** Every version manager
+ — mise, asdf, nvm, pyenv, rbenv, nodenv, fnm, sdkman — publishes its binaries
+ from `~/.bashrc`, and the stock Debian/Ubuntu `~/.bashrc` returns immediately
+ when not interactive. The symptom was a SessionEnd hook dying with
+ `node: not found` on a host where `which node` answers instantly. Remote runs
+ now build the PATH those managers would have set, after the `cd` rather than
+ before it, since mise and asdf pin a version per directory.
+
+- **It cannot take the run down with it.** The prelude is chain-safe: the caller
+ joins it to the `claude` invocation with `&&`, so it never ends on a failed
+ test, and a user's own `$CCS_REMOTE_INIT` runs under `eval` so a syntax error
+ there stays a runtime failure instead of a parse error that swallows everything
+ behind it. It sources no rc file — that output is a stream-json pipe, and one
+ MOTD banner derails the parse.
+
+- **The Test button reports it.** A connection test runs the same prelude and
+ says whether `node` and `claude` resolve, so a broken PATH shows up on the host
+ form instead of surfacing mid-turn as a hook failure that names the wrong
+ problem.
+
+### Fixes
+
+- The MCP task manager offered `haiku` / `sonnet` / `opus` in its model enum, so
+ an agent creating a task could not pick **Fable** — the fourth model the UI has
+ exposed since 7.4.
+
## 7.6.0
Bots stop being a web-only feature, and gain a mode where several of them talk to
@@ -102,6 +195,10 @@ remote writes, a remote poll loop, and somewhere for the agent's terminal to liv
agent with none of its prompt or model.
- The `/` command popup clamps each preview to two lines. A command's prompt can run to
thousands of characters, and one entry unrolled over the whole list.
+- `AGENTS.md` is read when a project has no `CLAUDE.md`. The `claude` CLI discovers only
+ the latter, so a project standardised on `AGENTS.md` ran with none of its conventions,
+ silently. Precedence is exclusive — when a `CLAUDE.md` exists the other is not read at
+ all — and the instruction-file editor opens whichever one the run actually uses.
## 7.5.4
diff --git a/CLAUDE.md b/CLAUDE.md
index 2df867bc..1b07f991 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -341,6 +341,26 @@ is testable without booting anything:
- **Only a chat with no session of its own is seeded.** `applyChatDefaults()` runs from
`newTab()`, from `switchProject()` and at boot — always behind `if (!currentSessionId)`.
An existing chat carries its own mode/model in SQLite and `loadSess()` must keep winning.
+- **The chain governs chat CREATION; execution channels keep their own budget.**
+ Both server-side doors to a new interactive chat resolve it via
+ `chatDefaultsForWorkdir()` (sessions have no `project_id` column, so the project is
+ matched by workdir the way `/api/activity` does): the WS `chat` frame and
+ `POST /api/sessions`. Both used to carry a private literal set — `sonnet`/`auto`/
+ `single`/`30` — so an API client or an older cached SPA created a chat on values
+ nobody had configured while the browser's toolbar showed the resolved ones. `_cd` is
+ hoisted ABOVE the `createSession` INSERT so the stored row and the run cannot disagree.
+ `chatDefaultsForWorkdir()` returns the FLAT `.effective` row, not the
+ `{effective, global, overridden}` envelope the REST endpoints hand the browser.
+- **Telegram and the scheduler deliberately do NOT inherit it** — `UNATTENDED_MAX_TURNS`
+ (30, declared once next to the `chat-defaults` require). A scheduled job that silently
+ inherited someone's `turns: 200` would burn a budget nobody was watching, and every
+ existing install would have jumped 30 → 50 on upgrade without being asked. The same
+ constant names every task/chain creation default (REST, MCP `create_task`/`create_chain`,
+ Kanban dispatch), so a bare `30` reappearing next to a runner is a review signal. If
+ per-channel budgets are ever wanted they belong in their own setting, not in the chat
+ dials. The `maxTurns || 30` floors inside the shared runners are left alone on purpose:
+ they fire only when a caller passes nothing at all, which is a different concern from
+ a channel policy. Pinned by `test/chat-defaults-api.test.js`.
### Markdown Rendering in SPA
- During streaming: `renderStreaming()` handles unclosed code fences
@@ -351,7 +371,7 @@ is testable without booting anything:
## How to Verify Changes
-`npm test` runs 63 test files under `test/` (18 `test/render/*.test.mjs` + 45 `test/*.test.js`). There is no CI yet, and nothing covers the live browser/WebSocket path, so also verify that manually:
+`npm test` runs 63 test files under `test/` (18 `test/render/*.test.mjs` + 45 `test/*.test.js`), and `.github/workflows/ci.yml` runs the same command on every push and PR to `main`. Nothing covers the live browser/WebSocket path, so also verify that manually:
```bash
# 1. Start server
diff --git a/README.md b/README.md
index e4e2a3e8..f3d54622 100644
--- a/README.md
+++ b/README.md
@@ -417,10 +417,14 @@ The active engine is always visible at a glance: a **⚡ Max** badge appears in
**Global default** — set your preferred engine once with the **★** button next to the toolbar selector (the star lights up when the current selection is your default). Every new chat — and every new **Kanban** card and **Scheduler** task — starts on it, while any individual chat or task can still override per-item. The engine selector lives in Chat, the Kanban task form, and the Scheduler form alike, and falls back to **API** automatically when `tmux` is unavailable (e.g. native Windows without WSL) — the Subscription option is disabled with a clear hint rather than failing only after you send.
+**New-chat defaults** — the same idea, extended to the whole toolbar. Pin **mode, agent mode, model, effort and turn budget** in Settings → *New-chat defaults*, and every new chat opens on them instead of on the built-ins. A project can override any subset of the five in its own settings; it stores **only the dials you actually pinned**, so the rest keep following the global value as you change it later. The chain is **project override → global default → built-in**, and every row shows which of the three it came from, with a one-click Reset. Existing chats are never re-seeded — each one carries its own settings in SQLite.
+
### 🌐 Remote Access & SSH
**SSH** — add remote servers, create projects pointing to directories on them. Claude works there as if local. Type `#` in chat for quick multi-server attachment. Screenshots and files auto-upload via SFTP.
+**Remote file browser** — the file tree, the file viewer and `@`-mention search now work on SSH projects as well, not only local ones. Read-only by design: list a directory, open a file. Download, share and image/PDF preview stay hidden for remote files instead of failing under your finger. Listings are capped at 2000 entries and files at 2 MB — both enforced on the remote host and announced in the UI, so an 8 GB log never crosses the link. A symlink pointing outside the project root is refused, not followed.
+
**Remote Access** — one click: cloudflared (no signup) or ngrok. Public HTTPS URL in seconds. Works behind NAT, firewalls, corporate VPNs. URL sent to Telegram automatically.
### 📊 Dashboard
@@ -485,12 +489,12 @@ npx github:Lexus2016/claude-code-studio # launch as usual
| **Modes** | Auto, Plan (read-only + Execute Plan), Task, auto mode switching |
| **Skills** | 30 built-in, auto-classification, plugin discovery, custom `.md` files |
| **Commands** | 10 built-in slash commands, custom commands |
-| **Remote** | SSH servers, SFTP upload, `#` quick-attach, cloudflared/ngrok tunnels, remote CLI session import |
+| **Remote** | SSH servers, SFTP upload, `#` quick-attach, cloudflared/ngrok tunnels, remote CLI session import, read-only remote file browser (tree + viewer + `@`-mention search over SSH, capped and symlink-guarded) |
| **Mobile** | Native-feel UI, bottom sheet, scroll-snap Kanban, iOS-safe, touch-optimized |
| **Dashboard** | Activity heatmap, tool usage, model distribution, Automation Index, peak hours |
| **Reliability** | security-hardened Telegram uploads, self-healing sessions, crash protection, atomic writes, instant stop, rate limit auto-wait, concurrency safety (session lock + busy_timeout), orphaned session lock auto-cleanup |
| **Workspace** | Global view across every project at once — one search over tasks and files, jump straight to the owning project |
-| **Settings** | In-app settings form, every value shown next to the source it comes from (shell env / `.env` / local `config.json` / global / project override / built-in default), override + shadow + ignored badges, secrets masked server-side, one-click reset to default, raw-file editors alongside |
+| **Settings** | In-app settings form, every value shown next to the source it comes from (shell env / `.env` / local `config.json` / global / project override / built-in default), override + shadow + ignored badges, secrets masked server-side, one-click reset to default, raw-file editors alongside, global new-chat defaults (mode / agent / model / effort / turns) with sparse per-project overrides |
| **Security** | bcrypt auth, AES-256-GCM SSH, Helmet.js, path traversal protection, XSS/SQLi prevention |
| **Platform** | Windows/macOS/Linux, Docker (non-root, registry mirror), LLM proxy/gateway, 5 languages (EN/UA/RU/FR/HE), OpenRouter support |
diff --git a/mcp-task-manager.js b/mcp-task-manager.js
index ed8a46b5..c067e0e4 100644
--- a/mcp-task-manager.js
+++ b/mcp-task-manager.js
@@ -46,7 +46,7 @@ const TOOLS = [
context: {
description: 'Curated context to pass to the child task. Include ONLY what the child needs — issue details, code snippets, error messages, etc. Can be a string or a JSON object. The child reads this via get_current_task.',
},
- model: { type: 'string', enum: ['haiku', 'sonnet', 'opus'], description: 'AI model for the task (default: inherit from current task)' },
+ model: { type: 'string', enum: ['haiku', 'sonnet', 'opus', 'fable'], description: 'AI model for the task (default: inherit from current task)' },
depends_on: { type: 'array', items: { type: 'string' }, description: 'Task IDs that must complete before this task starts' },
chain_id: { type: 'string', description: 'Add this task to an existing chain' },
scheduled_at: { type: 'string', description: 'ISO 8601 datetime for delayed execution' },
@@ -70,7 +70,7 @@ const TOOLS = [
title: { type: 'string' },
description: { type: 'string' },
context: { description: 'Context for this specific task' },
- model: { type: 'string', enum: ['haiku', 'sonnet', 'opus'] },
+ model: { type: 'string', enum: ['haiku', 'sonnet', 'opus', 'fable'] },
max_turns: { type: 'number' },
depends_on_index: { type: 'array', items: { type: 'number' }, description: 'Indices (0-based) of tasks in this chain that must complete first. If omitted, depends on previous task.' },
},
@@ -78,7 +78,7 @@ const TOOLS = [
},
description: 'Array of task definitions (max 10)',
},
- model: { type: 'string', enum: ['haiku', 'sonnet', 'opus'], description: 'Default model for all tasks' },
+ model: { type: 'string', enum: ['haiku', 'sonnet', 'opus', 'fable'], description: 'Default model for all tasks' },
scheduled_at: { type: 'string', description: 'ISO 8601 datetime for delayed execution' },
recurrence: { type: 'string', pattern: '^(hourly|daily|weekly|monthly|every:[1-9][0-9]*:(hour|day|week|month)|times:[1-9][0-9]*:month)$', description: 'Repeat schedule. A preset (hourly|daily|weekly|monthly), or "every:N:unit" where unit = hour|day|week|month (e.g. "every:2:hour", "every:10:day", "every:6:month"), or "times:N:month" = N runs per calendar month (e.g. "times:3:month").' },
recurrence_end_at: { type: 'string', description: 'ISO 8601 datetime to stop recurring' },
diff --git a/package.json b/package.json
index 885a5b53..8191ee4d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "claude-code-studio",
- "version": "7.6.0",
+ "version": "7.7.0",
"description": "Full-featured web workspace for Claude Code — chat, Kanban, multi-agent, MCP, skills, projects",
"bin": {
"claude-code-studio": "./bin/cli.js"
diff --git a/public/index.html b/public/index.html
index eee305b2..4563f459 100644
--- a/public/index.html
+++ b/public/index.html
@@ -11613,7 +11613,11 @@
Делегувати агенту
// send the API path (claude -p) regardless of any saved/default engine. Restore: engine: curEngine,
engine: 'api',
effort: curEffort || undefined,
- maxTurns: parseInt($i('maxTurns').value) || 30,
+ // An empty turns box must fall back to the user's CONFIGURED budget, not to a
+ // private literal: this is the value that actually goes on the wire, and 30 was
+ // neither the toolbar's seed nor the server's built-in. `_chatDefaults` holds the
+ // resolved project > global > built-in chain fetched at boot.
+ maxTurns: parseInt($i('maxTurns').value) || (_chatDefaults?.effective?.turns || 50),
workdir: curWorkdir || undefined,
}));
}
diff --git a/server.js b/server.js
index bd7add24..45d9f5b4 100644
--- a/server.js
+++ b/server.js
@@ -95,6 +95,16 @@ const agentsMd = require('./agents-md');
// purpose — see the header of the module.
const remoteFiles = require('./remote-files');
const chatDefaults = require('./chat-defaults');
+
+// Turn budget for the UNATTENDED execution channels — a Telegram message and a
+// scheduled task. Deliberately NOT wired to `chatDefaults.turns` (#58): that chain
+// governs the toolbar a NEW INTERACTIVE CHAT opens on, where a human watches the run
+// and can stop it. A scheduled job that silently inherited someone's turns=200 would
+// burn a budget nobody was watching, and every existing install would have jumped
+// from 30 to 50 on upgrade without asking. If per-channel budgets are ever wanted,
+// they belong in their own setting, not in the chat dials.
+const UNATTENDED_MAX_TURNS = 30;
+
const { isTransientOverload, shouldRetryOverload, detectUsageLimit, taskStatusForStop } = require('./rate-limit-utils');
const { buildTerminalCommand: buildDelegateCommand, winTerminalArgs } = require('./delegate-terminal');
const { isAgentSuccess, shouldAutoContinue, agentStopReason } = require('./multi-agent-result');
@@ -1750,7 +1760,7 @@ async function startTask(task) {
let currentTaskCid = claudeSessionId;
let lastTaskResult = null;
let lastTaskTurnUsage = null; // usage of the most recent assistant turn → real ctx-window occupancy
- const effectiveTaskMaxTurns = task.max_turns || 30;
+ const effectiveTaskMaxTurns = task.max_turns || UNATTENDED_MAX_TURNS;
// Build MCP config for task execution — user MCPs from config + internal task-manager
const taskMcpServers = {};
@@ -3275,6 +3285,28 @@ async function classifyTask(userMessage, currentSkills, config, workdir) {
function loadProjects() { try { return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf-8')); } catch { return []; } }
function saveProjects(p) { const d=path.dirname(PROJECTS_FILE); if(!fs.existsSync(d)) fs.mkdirSync(d,{recursive:true}); atomicWriteJSON(PROJECTS_FILE, p); }
+/**
+ * The five new-chat dials (#58) resolved for a workdir: project override > global > BUILTIN.
+ * Sessions carry no `project_id` column, so the project is matched by workdir the same way
+ * /api/activity does. Used as the WS `chat` fallback for a client that omits a field — an
+ * older cached SPA, an API client, a hand-built message. Before this it fell to a private
+ * literal set (turns 30, model sonnet) that matched neither the toolbar nor BUILTIN, so the
+ * same omitted field answered differently depending on which door the turn came through.
+ * @param {string|null} workdir
+ */
+function chatDefaultsForWorkdir(workdir) {
+ let proj = null;
+ if (workdir) {
+ let key; try { key = path.resolve(workdir); } catch { key = workdir; }
+ proj = loadProjects().find(p => {
+ try { return path.resolve(p.workdir) === key; } catch { return false; }
+ }) || null;
+ }
+ // The FLAT effective row, not the {effective, global, overridden} envelope the
+ // REST endpoints hand the browser: callers here want a value per dial.
+ return chatDefaults.resolveChatDefaults(loadMergedConfig().chatDefaults, proj && proj.defaults).effective;
+}
+
/**
* Get notification context (session title + project name) for enriching notification payloads.
* @param {string} sessionId - session ID to look up
@@ -5042,7 +5074,7 @@ app.post('/api/internal/task-manager', express.json({ limit: '1mb' }), (req, res
model || callerTask?.model || 'sonnet',
mode || callerTask?.mode || 'auto',
agent_mode || callerTask?.agent_mode || 'single',
- max_turns || callerTask?.max_turns || 30,
+ max_turns || callerTask?.max_turns || UNATTENDED_MAX_TURNS,
null, // attachments
depsJson,
chain_id || null,
@@ -5117,7 +5149,7 @@ app.post('/api/internal/task-manager', express.json({ limit: '1mb' }), (req, res
'auto', 'single', effectiveModel, workdir);
const effectiveEffort = chainEffort || callerTask?.effort || null;
stmts.createChain.run(chainId, String(title).substring(0, 200), workdir,
- effectiveModel, 'auto', 'single', 30,
+ effectiveModel, 'auto', 'single', UNATTENDED_MAX_TURNS,
chainSessionId, toUnixTs(chainScheduledAt), recurrence || null,
toUnixTs(recurrence_end_at), callerTask?.source_session_id || null, 0,
effectiveEffort);
@@ -5154,7 +5186,7 @@ app.post('/api/internal/task-manager', express.json({ limit: '1mb' }), (req, res
workdir,
td.model || effectiveModel,
'auto', 'single',
- td.max_turns || 30,
+ td.max_turns || UNATTENDED_MAX_TURNS,
null, // attachments
depsJson,
chainId,
@@ -6181,7 +6213,7 @@ function badBotId(value) {
app.post('/api/tasks', (req, res) => {
const { title=i18nTask(), description='', notes='', status='backlog', sort_order=0, session_id=null, workdir=null,
- model='sonnet', mode='auto', agent_mode='single', max_turns=30, attachments=null,
+ model='sonnet', mode='auto', agent_mode='single', max_turns=UNATTENDED_MAX_TURNS, attachments=null,
depends_on=null, chain_id=null, source_session_id=null,
scheduled_at=null, recurrence=null, recurrence_end_at=null, effort=null, run_engine=null,
bot_id=null } = req.body;
@@ -6204,7 +6236,7 @@ app.put('/api/tasks/:id', (req, res) => {
status=task.status, sort_order=task.sort_order,
session_id=task.session_id, workdir=task.workdir,
model=task.model||'sonnet', mode=task.mode||'auto', agent_mode=task.agent_mode||'single',
- max_turns=task.max_turns||30, attachments=task.attachments,
+ max_turns=task.max_turns||UNATTENDED_MAX_TURNS, attachments=task.attachments,
depends_on=task.depends_on, chain_id=task.chain_id, source_session_id=task.source_session_id,
scheduled_at=task.scheduled_at, recurrence=task.recurrence, recurrence_end_at=task.recurrence_end_at,
effort=task.effort, run_engine=task.run_engine,
@@ -6288,7 +6320,7 @@ app.get('/api/task-chains/:id', (req, res) => {
});
app.post('/api/task-chains', (req, res) => {
const { title = 'Task Group', workdir = null, model = 'sonnet', mode = 'auto',
- agent_mode = 'single', max_turns = 30, scheduled_at = null,
+ agent_mode = 'single', max_turns = UNATTENDED_MAX_TURNS, scheduled_at = null,
recurrence = null, recurrence_end_at = null, effort = null } = req.body;
const id = genId();
// Create shared session for the chain
@@ -6355,7 +6387,7 @@ app.post('/api/task-chains/:id/tasks', (req, res) => {
stmts.createTask.run(taskId, String(title).substring(0, 200), String(description).substring(0, 2000),
String(notes || '').substring(0, 2000), taskStatus, sortOrder,
chain.session_id || null, chain.workdir || null, chain.model || 'sonnet',
- chain.mode || 'auto', chain.agent_mode || 'single', chain.max_turns || 30,
+ chain.mode || 'auto', chain.agent_mode || 'single', chain.max_turns || UNATTENDED_MAX_TURNS,
null, dependsOn, req.params.id, chain.source_session_id || null,
chain.scheduled_at || null, null, null, chain.effort || null, chain.run_engine || null,
null // bot_id — commit a38e2a4 added this column to the INSERT but updated only
@@ -6486,7 +6518,7 @@ app.post('/api/tasks/dispatch', (req, res) => {
// Register chain in task_chains table (gives it a title, session, and metadata)
stmts.createChain.run(chainId, (plan_description || 'Task chain').substring(0, 200),
- sqlVal(workdir) || null, sqlVal(model) || 'sonnet', 'auto', 'single', 30,
+ sqlVal(workdir) || null, sqlVal(model) || 'sonnet', 'auto', 'single', UNATTENDED_MAX_TURNS,
chainSessionId, null, null, null, source_session_id || null, 0, sqlVal(effort) || null);
// Chain gets its OWN Claude session — first task starts fresh,
@@ -6513,7 +6545,7 @@ app.post('/api/tasks/dispatch', (req, res) => {
chainSessionId,
sqlVal(workdir) || null,
sqlVal(model) || 'sonnet',
- 'auto', 'single', 30,
+ 'auto', 'single', UNATTENDED_MAX_TURNS,
null, // attachments
realDeps.length ? JSON.stringify(realDeps) : null,
chainId,
@@ -6542,7 +6574,11 @@ app.get('/api/sessions', (req,res) => {
res.json(workdir ? stmts.getSessionsByWorkdir.all(workdir) : stmts.getSessions.all());
});
app.post('/api/sessions', (req, res) => {
- const { title = i18nSession(), workdir = null, model = 'sonnet', mode = 'auto', agentMode = 'single', kind = 'chat', terminalAgent = null } = req.body || {};
+ // The other door to creating an interactive chat, so it resolves the same #58 chain
+ // as the WS `chat` frame. The SPA sends its toolbar values; this fires for API
+ // clients and older builds, which used to land on literals nobody had configured.
+ const _cd = chatDefaultsForWorkdir((req.body || {}).workdir || null);
+ const { title = i18nSession(), workdir = null, model = _cd.model, mode = _cd.mode, agentMode = _cd.agent, kind = 'chat', terminalAgent = null } = req.body || {};
// This value ends up as the cwd of `claude --dangerously-skip-permissions`. Same
// rule as every other endpoint that takes a path from the client.
if (workdir && !isWorkdirAllowed(workdir)) return res.status(400).json({ error: 'workdir is outside the allowed roots' });
@@ -8775,7 +8811,7 @@ async function processTelegramChat({ sessionId, text, userId, chatId, threadId,
systemPrompt,
mcpServers,
model,
- maxTurns: 30,
+ maxTurns: UNATTENDED_MAX_TURNS,
ws: proxy,
sessionId,
abortController,
@@ -10340,10 +10376,17 @@ wss.on('connection', (ws) => {
existSess = null;
}
+ // Fallbacks for a dial the client omitted come from the #58 chain, not from a
+ // private literal set: an older cached SPA, an API client or a hand-built frame
+ // must land on the same defaults the toolbar would have seeded. Resolved from
+ // `msg.workdir` because the chain's first link is the per-project override, and
+ // hoisted above the INSERT so the stored ROW and the RUN cannot disagree.
+ const _cd = chatDefaultsForWorkdir(msg.workdir || null);
+
let isNewSession = false;
if (!localSessionId || !existSess) {
localSessionId = genId();
- stmts.createSession.run(localSessionId,i18nSession(),'[]','[]',sqlVal(msg.mode)||'auto',sqlVal(msg.agentMode)||'single',sqlVal(msg.model)||'sonnet',sqlVal(msg.workdir)||null);
+ stmts.createSession.run(localSessionId,i18nSession(),'[]','[]',sqlVal(msg.mode)||_cd.mode,sqlVal(msg.agentMode)||_cd.agent,sqlVal(msg.model)||_cd.model,sqlVal(msg.workdir)||null);
isNewSession = true;
} else {
localClaudeId = sanitizeSessionId(existSess.claude_session_id) || undefined;
@@ -10373,7 +10416,7 @@ wss.on('connection', (ws) => {
}
}
- const { text:userMessage, attachments=[], skills:sIds=[], mcpServers:mIds=[], mode='auto', agentMode='single', model='sonnet', maxTurns=30, workdir=null, reply_to=null, retry=false, autoSkill=false, effort=null, engine='api' } = msg;
+ const { text:userMessage, attachments=[], skills:sIds=[], mcpServers:mIds=[], mode=_cd.mode, agentMode=_cd.agent, model=_cd.model, maxTurns=_cd.turns, workdir=null, reply_to=null, retry=false, autoSkill=false, effort=chatDefaults.effortToFlag(_cd.effort) || null, engine='api' } = msg;
let replyQuote = '';
if (reply_to && reply_to.content) {
@@ -11513,7 +11556,7 @@ wss.on('connection', (ws) => {
);
// Register chain in task_chains table
stmts.createChain.run(chainId, (finalPlan || 'Task chain').substring(0, 200),
- sqlVal(workdir) || null, sqlVal(model) || 'sonnet', 'auto', 'single', 30,
+ sqlVal(workdir) || null, sqlVal(model) || 'sonnet', 'auto', 'single', UNATTENDED_MAX_TURNS,
chainSessionId, null, null, null, sessionId || null, 0, sqlVal(effort) || null);
// Chain gets its OWN Claude session — first task starts fresh,
// subsequent tasks --resume from the chain's session (NOT the source chat's).
@@ -11534,7 +11577,7 @@ wss.on('connection', (ws) => {
(a.role || 'Subtask').substring(0, 200),
(a.task || '').substring(0, 2000),
'', 'todo', i, chainSessionId, sqlVal(workdir) || null,
- sqlVal(model) || 'sonnet', 'auto', 'single', 30, null,
+ sqlVal(model) || 'sonnet', 'auto', 'single', UNATTENDED_MAX_TURNS, null,
realDeps.length ? JSON.stringify(realDeps) : null,
chainId, sessionId || null,
null, null, null, // scheduled_at, recurrence, recurrence_end_at
diff --git a/test/chat-defaults-api.test.js b/test/chat-defaults-api.test.js
index ef329033..c49f5cb6 100644
--- a/test/chat-defaults-api.test.js
+++ b/test/chat-defaults-api.test.js
@@ -230,6 +230,58 @@ const storedDefaults = id => {
['chatDefaults.mode', 'chatDefaults.agent', 'chatDefaults.model', 'chatDefaults.effort', 'chatDefaults.turns']);
check('and the section itself is declared', resolved.json.sections.includes('defaults'), true);
+ console.log('\n— the chain reaches session creation, not just the toolbar —');
+ // POST /api/sessions is the second door to an interactive chat (the WS `chat`
+ // frame is the first). Before this it fell to its own literals — 'sonnet' /
+ // 'auto' / 'single' — so an API client or an older cached SPA created a chat on
+ // values nobody had configured, while the browser's toolbar showed the resolved
+ // ones. `mode` here comes from the LOCAL config.json and `agent_mode` from the
+ // GLOBAL one, so a single row also re-proves the per-layer sanitising: spread
+ // the two files raw and the local `agent:''` masks the global 'multi'.
+ // Asserted against what the resolver reports RIGHT HERE rather than against the
+ // seeded literals: earlier blocks in this file deliberately rewrite the global
+ // row, and the property worth pinning is "the created row equals the chain",
+ // whatever the chain currently says.
+ const chainB = (await defaultsOf(B)).effective;
+ const sessB = await api('POST', '/api/sessions', { workdir: PROJ_B });
+ check('an omitted mode is seeded from the resolved chain', sessB.json.mode, chainB.mode);
+ check('and so is an omitted agent mode', sessB.json.agent_mode, chainB.agent);
+ check('and an omitted model', sessB.json.model, chainB.model);
+ // A pinned {model:'haiku'} a few assertions above; B pinned nothing.
+ const chainA = (await defaultsOf(A)).effective;
+ check('the two projects really do resolve differently', chainA.model === chainB.model, false);
+ const sessA = await api('POST', '/api/sessions', { workdir: PROJ_A });
+ check('a project override wins over both config layers', sessA.json.model, 'haiku');
+ check('while the dials it did not pin still follow the global', sessA.json.mode, chainB.mode);
+ const sessX = await api('POST', '/api/sessions', { workdir: PROJ_A, model: 'opus', mode: 'plan' });
+ check('an explicit value still beats the whole chain', [sessX.json.model, sessX.json.mode], ['opus', 'plan']);
+
+ console.log('\n— the unattended channels deliberately do NOT inherit —');
+ // Telegram and the scheduler are execution channels, not chat creation. A
+ // scheduled job that silently inherited someone\'s turns=200 would burn a budget
+ // nobody was watching, and every existing install would have jumped 30 -> 50 on
+ // upgrade without being asked. The decision is recorded as a named constant, so
+ // a bare 30 reappearing next to a runner is a review signal rather than noise.
+ const srvSrc = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
+ check('the unattended budget is declared exactly once',
+ (srvSrc.match(/^const UNATTENDED_MAX_TURNS = \d+;$/m) || []).length, 1);
+ check('and it is still 30 — changing it changes every install on upgrade',
+ /^const UNATTENDED_MAX_TURNS = 30;$/m.test(srvSrc), true);
+ check('the scheduled-task runner uses it',
+ /const effectiveTaskMaxTurns = task\.max_turns \|\| UNATTENDED_MAX_TURNS;/.test(srvSrc), true);
+ check('the Telegram path uses it', /maxTurns: UNATTENDED_MAX_TURNS,/.test(srvSrc), true);
+ // The WS `chat` frame is the interactive door, so it must NOT carry a private
+ // literal set any more.
+ const wsLine = srvSrc.split('\n').find(l => l.includes('const { text:userMessage, attachments=[]'));
+ check('the WS chat frame no longer defaults turns to a literal', /maxTurns=\d/.test(wsLine), false);
+ check('it resolves them from the chain instead', /maxTurns=_cd\.turns/.test(wsLine), true);
+ check('and the same for mode, agent and model',
+ ['mode=_cd.mode', 'agentMode=_cd.agent', 'model=_cd.model'].every(f => wsLine.includes(f)), true);
+ // The stored row and the run have to agree: `_cd` is hoisted above the INSERT.
+ check('the resolved defaults are hoisted above the session INSERT',
+ srvSrc.indexOf('const _cd = chatDefaultsForWorkdir(msg.workdir')
+ < srvSrc.indexOf('stmts.createSession.run(localSessionId'), true);
+
cleanup();
console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'} — ${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
diff --git a/test/interrupt-idle-config.test.js b/test/interrupt-idle-config.test.js
index 7f5fb5ef..bbed82d0 100644
--- a/test/interrupt-idle-config.test.js
+++ b/test/interrupt-idle-config.test.js
@@ -132,9 +132,15 @@ const ENGINE_WRITE = pick(/db\.prepare\(`UPDATE sessions SET run_engine=\? WHERE
// Feed one dispatched message through processChat's own config-persisting path.
function persistConfigFor(dispatchedMsg) {
+ // The lifted destructure resolves its fallbacks through the #58 chain, so the
+ // sandbox has to supply the same two names processChat has in scope. Built from
+ // the real module rather than stubbed: if a dial's built-in ever changes, this
+ // test keeps measuring the write-back against what the server would really use.
+ const chatDefaults = require('../chat-defaults');
+ const _cd = chatDefaults.resolveChatDefaults(null, null).effective;
const sandbox = {
msg: dispatchedMsg, stmts, db, JSON, Buffer, Object, Array, Number,
- localSessionId: 'S1', log: { error() {} },
+ localSessionId: 'S1', log: { error() {} }, chatDefaults, _cd,
};
vm.createContext(sandbox);
vm.runInContext(`