From 5e9efcc484b370fa4ff8017e7c135a83a8348b75 Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Sun, 23 Aug 2026 20:03:40 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20release=207.7.0=20=E2=80=94=20the=20#5?= =?UTF-8?q?8=20chain=20reaches=20the=20server,=20not=20just=20the=20toolba?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-chat defaults chain (#58) governed the browser toolbar but stopped there. Both server-side doors to a new interactive chat — the WebSocket `chat` frame and `POST /api/sessions` — carried their own literal set (`sonnet` / `auto` / `single` / 30 turns), so an API client or a browser running a cached older build created chats on values nobody had configured while the toolbar on screen showed the resolved ones. Both now resolve the same chain via `chatDefaultsForWorkdir()`, and the resolved row is computed above the `createSession` INSERT so the stored row and the run cannot disagree. The unattended execution channels deliberately do NOT inherit it. Telegram and the scheduler keep a 30-turn budget, now under a name (`UNATTENDED_MAX_TURNS`) instead of 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 30 → 50 on upgrade without being asked. Also: - an empty turn-budget box now falls back to the configured default rather than to a literal 30 that matched neither the box's placeholder nor BUILTIN; - the MCP task manager's model enum gained `fable`, so an agent creating a task can pick the fourth model the UI has exposed since 7.4; - CHANGELOG / README / CLAUDE.md updated for 7.7.0. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 97 ++++++++++++++++++++++++++++++ CLAUDE.md | 22 ++++++- README.md | 8 ++- mcp-task-manager.js | 6 +- package.json | 2 +- public/index.html | 6 +- server.js | 75 ++++++++++++++++++----- test/chat-defaults-api.test.js | 52 ++++++++++++++++ test/interrupt-idle-config.test.js | 8 ++- 9 files changed, 251 insertions(+), 25 deletions(-) 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 @@

Делегувати агенту 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(`