@bridge4dev/runner 0.13.1 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +402 -4
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +28 -1
  35. package/dist/service-unit.d.ts +48 -1
  36. package/dist/service-unit.js +109 -4
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
@@ -1,8 +1,12 @@
1
1
  import { query, } from '@anthropic-ai/claude-agent-sdk';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
2
4
  import { AsyncQueue } from '../async-queue.js';
3
5
  import { log } from '../log.js';
6
+ import { mcpConfigPath } from '../paths.js';
4
7
  import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
5
8
  import { AGENT_MODES, } from './types.js';
9
+ import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
6
10
  // Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
7
11
  // 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
8
12
  // never populate allowedTools with gated tools (we don't set it at all).
@@ -51,7 +55,8 @@ const ENV_ALLOWLIST = [
51
55
  // Subscription token — legitimate agent auth when the user configured it.
52
56
  'CLAUDE_CODE_OAUTH_TOKEN',
53
57
  ];
54
- function scrubbedEnv() {
58
+ /** Exported for the one-shot commit-message run (session 14), same rules. */
59
+ export function scrubbedEnv() {
55
60
  const env = {};
56
61
  for (const key of ENV_ALLOWLIST) {
57
62
  const value = process.env[key];
@@ -74,62 +79,344 @@ const SYSTEM_APPEND = [
74
79
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
75
80
  'Rules:',
76
81
  '- Work ONLY inside the current working directory (a dedicated git worktree on a session branch).',
77
- '- Commit your work in the current branch with clear messages. NEVER push to main/master and never force-push.',
82
+ // Session 13: pushing is now refused outright by layer 1, in every trust
83
+ // mode. Saying so here is not a duplicate of the rule — it saves the agent a
84
+ // turn spent discovering the refusal, and tells it what to do instead.
85
+ '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked. A human presses «Push» and «Apply» in DevBridge when the branch is ready.',
78
86
  '- If DevBridge MCP tools (mcp__devbridge__*) are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
79
- '- The user is not in a terminal: if you need a decision or information, ask the question in plain text and end your turn — the answer arrives as the next user message.',
87
+ '- The user is not in a terminal, but they DO answer: when you need a decision, use the AskUserQuestion tool. It is rendered as a card in the DevBridge dashboard and the call waits — however long it takes — until a human answers it. Only ask in plain text if the tool is unavailable.',
88
+ '- Never decide for the user when you asked them a question. If the tool comes back saying the question was withdrawn, stop and wait rather than guessing.',
80
89
  '- Never print secrets (tokens, API keys, private keys) in your output.',
81
90
  ].join('\n');
91
+ /** DevBridge's own rules, then whatever this workspace adds (session 13). */
92
+ function composeSystemAppend(workspaceContext) {
93
+ return workspaceContext ? `${SYSTEM_APPEND}\n\n${workspaceContext}` : SYSTEM_APPEND;
94
+ }
95
+ /**
96
+ * The five levels the Agent SDK declares, and the one gate that keeps a
97
+ * stale or invented one out of a query option (ticket #111).
98
+ *
99
+ * The CLI rejects the whole query on a bad `effort`, not just the option — so a
100
+ * level pinned against a model that no longer offers it would take the session
101
+ * down at launch rather than degrade.
102
+ */
103
+ const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
104
+ function isEffortLevel(value) {
105
+ return typeof value === 'string' && EFFORT_LEVELS.includes(value);
106
+ }
107
+ /**
108
+ * The sixth rung the CLI's own `/effort` offers — and the one that is NOT a
109
+ * level (owner, 2026-07-29: «нету уровня еффорта для клода ультракод»).
110
+ *
111
+ * `ultracode` is a separate boolean in the settings layer, not a member of
112
+ * `EffortLevel`: the SDK describes it as «xhigh effort plus standing
113
+ * dynamic-workflow orchestration», session-scoped and never persisted. Verified
114
+ * live — `applyFlagSettings({ ultracode: true })` is accepted and the next turn
115
+ * of the same process reports `CLAUDE_EFFORT=xhigh` with the Workflow tool in
116
+ * play.
117
+ *
118
+ * It rides in the same picker because that is where the user looks for it and
119
+ * where the CLI itself puts it, but everything below has to keep the two apart:
120
+ * sending it as an `effortLevel` would be rejected, and leaving the flag set
121
+ * while a plain level is chosen would pin every later turn to xhigh.
122
+ */
123
+ const ULTRACODE = 'ultracode';
124
+ function isEffortPin(value) {
125
+ return value === ULTRACODE || isEffortLevel(value);
126
+ }
127
+ const EFFORT_LABELS = {
128
+ low: 'Low',
129
+ medium: 'Medium',
130
+ high: 'High',
131
+ xhigh: 'Extra high',
132
+ max: 'Max',
133
+ [ULTRACODE]: 'Ultracode',
134
+ };
135
+ const EFFORT_DESCRIPTIONS = {
136
+ [ULTRACODE]: 'Extra high, plus the agent orchestrates sub-agent workflows on its own',
137
+ };
138
+ /** Longest a task title may be before the tray truncates it. */
139
+ const TASK_TITLE_LIMIT = 120;
140
+ /** How many live tasks travel in one `agent_tasks` event. */
141
+ const TASK_LIST_CAP = 20;
142
+ /**
143
+ * Floor between two `agent_tasks` publications.
144
+ *
145
+ * A twenty-agent fan-out changes membership dozens of times a minute and every
146
+ * running subagent adds a `task_progress` every ~30s. Each event is a row in
147
+ * `DevSessionEvent`, so the tray coalesces rather than narrating.
148
+ */
149
+ const TASK_PUBLISH_INTERVAL_MS = 1_500;
150
+ /** What the agent calls a finished task, mapped onto the tray's three states. */
151
+ function taskStatus(raw) {
152
+ if (raw === 'completed')
153
+ return 'done';
154
+ if (raw === 'failed' || raw === 'killed')
155
+ return 'failed';
156
+ return 'running';
157
+ }
158
+ /**
159
+ * The `usage` block of a `task_progress` / `task_notification`, read one field
160
+ * at a time. Each is optional on its own: an older CLI that reports only tokens
161
+ * must still get its tokens through, not have the whole block dropped.
162
+ */
163
+ function usagePatch(value) {
164
+ if (!value || typeof value !== 'object')
165
+ return {};
166
+ const usage = value;
167
+ return {
168
+ ...(typeof usage['total_tokens'] === 'number' ? { tokens: usage['total_tokens'] } : {}),
169
+ ...(typeof usage['tool_uses'] === 'number' ? { toolUses: usage['tool_uses'] } : {}),
170
+ ...(typeof usage['duration_ms'] === 'number' ? { durationMs: usage['duration_ms'] } : {}),
171
+ };
172
+ }
82
173
  class ClaudeSession {
83
174
  spec;
84
175
  input = new AsyncQueue();
85
176
  output = new AsyncQueue();
86
177
  pending = new Map();
178
+ /** Parked question calls, keyed by the askId this adapter issued. */
179
+ pendingQuestions = new Map();
87
180
  q;
88
181
  stopped = false;
182
+ /** Path of the 0600 MCP config for this session, or null (ticket #119). */
183
+ mcpConfigFile = null;
184
+ /** Set when the write failed and the key went into argv after all. */
185
+ mcpFallbackNotice = null;
89
186
  /** Guards against overlapping capability probes. */
90
187
  capabilitiesInFlight = false;
91
188
  mode;
92
189
  model;
190
+ /**
191
+ * Reasoning effort pinned for this session (ticket #111).
192
+ *
193
+ * Claude Code has had this dial for a while — the SDK takes `effort` as a
194
+ * query option and every model row says which levels it accepts — and this
195
+ * adapter simply never asked for it, so the dashboard drew no control and
196
+ * every session ran at the model's default. It is a launch-time option with
197
+ * no live setter, so changing it relaunches the process the same way a model
198
+ * change used to before `setModel` existed: the pin is kept here and applied
199
+ * when the supervisor next starts the agent.
200
+ */
201
+ effort;
202
+ /**
203
+ * The last model catalogue the CLI reported, kept so a wire id can be
204
+ * resolved back to the row it came from (ticket #111).
205
+ *
206
+ * `supportedModels()` answers in ALIASES (`default`, `sonnet`, `opus[1m]`),
207
+ * `system:init` answers with the RESOLVED id (`claude-opus-5[1m]`). Without
208
+ * this map the two never meet.
209
+ */
210
+ knownModels = [];
211
+ /**
212
+ * The wire model the CLI last reported, kept RAW (QA-111 M3).
213
+ *
214
+ * `system:init` and the catalogue probe race, and `system:init` can win: the
215
+ * catalogue arrives as the answer to a control request, nothing orders the
216
+ * two. Resolving a wire id against an EMPTY catalogue used to overwrite
217
+ * `this.model` with the wire id — destroying the alias the user picked — and
218
+ * the later re-ask then had only the wire id to go on, so it asked the
219
+ * CATALOGUE «who owns this?» and got `default`. A session started on Opus
220
+ * silently became a session on whatever the account is recommended today,
221
+ * and `persistAgentState` wrote that into the session row.
222
+ *
223
+ * So the raw id waits here until there is a catalogue to read it against.
224
+ */
225
+ liveWireModel;
226
+ /**
227
+ * Everything running beside the conversation, keyed by the agent's task id
228
+ * (ticket #113). Membership is owned by `background_tasks_changed`; the
229
+ * details come from the `task_*` bookends.
230
+ */
231
+ tasks = new Map();
232
+ /** Ids currently in the agent's live set — the tray shows exactly these. */
233
+ liveTaskIds = [];
234
+ /** Started / finished within the CURRENT turn: the «14 of 20» numerator. */
235
+ turnTasksStarted = 0;
236
+ turnTasksDone = 0;
237
+ taskPublishTimer = null;
238
+ taskPublishedAt = 0;
239
+ /** Last published snapshot, minus the ages — see `flushTasks` (QA-111 M4). */
240
+ lastTaskFingerprint = '';
241
+ /**
242
+ * Ambient tasks the SDK marked `skip_transcript`. Remembered by id because
243
+ * the flag arrives on `task_started`, which is usually NOT the first message
244
+ * about the task — the live set typically precedes it (QA-111 m4).
245
+ */
246
+ skipTaskIds = new Set();
93
247
  events = this.output;
94
248
  constructor(spec, queryFn) {
95
249
  this.spec = spec;
96
250
  this.mode = spec.mode ?? 'ask';
97
251
  if (spec.model)
98
252
  this.model = spec.model;
253
+ // Gated, not copied: a pin stored against a model that no longer offers it
254
+ // would otherwise be reported as the level in force while the CLI ignored
255
+ // it — and `applyFlagSettings` would be handed a level it rejects.
256
+ if (isEffortPin(spec.effort))
257
+ this.effort = spec.effort;
99
258
  // Free CHAT sessions start with no prompt: the process boots, reports its
100
259
  // capabilities and waits for the first message (live-verified 2026-07-24).
101
260
  if (spec.prompt && spec.prompt.trim())
102
261
  this.pushUserText(spec.prompt);
262
+ // Ticket #119: the same object the SDK used to inline into argv, now written
263
+ // to a 0600 file before the CLI is spawned and removed in `stop()`.
264
+ const mcpServers = spec.mcp
265
+ ? {
266
+ devbridge: {
267
+ type: 'http',
268
+ url: spec.mcp.url,
269
+ headers: { Authorization: `Bearer ${spec.mcp.token}` },
270
+ },
271
+ }
272
+ : null;
273
+ // If the write fails we fall back to the old inline shape. Losing the
274
+ // hardening is bad; losing the agent's MCP tools mid-session is worse, and
275
+ // that is the trade this line makes — loudly, via `log.warn` inside.
276
+ const mcpConfigFile = mcpServers ? this.writeMcpConfig(mcpServers) : null;
277
+ this.mcpConfigFile = mcpConfigFile;
103
278
  const options = {
104
279
  cwd: spec.cwd,
105
280
  env: scrubbedEnv(),
106
- settingSources: [],
281
+ /**
282
+ * Everything the machine's own Claude has (owner's call, 2026-07-30).
283
+ *
284
+ * This was `[]` from session 13 onward — SDK isolation mode — and it cost
285
+ * the sessions most of what the terminal has: the 20 agents in
286
+ * `~/.claude/agents`, the `sc:*` and superpowers plugins, and every MCP
287
+ * server the machine is configured with, playwright included. A session
288
+ * could not open a page and look at its own change; it could only say
289
+ * «done, please check».
290
+ *
291
+ * The owner runs the same agent, on the same machine, with the same
292
+ * powers, from a terminal. Parity is the point: a session should be able
293
+ * to do what its owner can do there.
294
+ *
295
+ * Two consequences that are NOT bugs and should not be surprises:
296
+ * - hooks from `~/.claude/settings.json` fire for sessions too;
297
+ * - `.claude/settings.json` lives inside the repository, so an agent can
298
+ * edit the file that configures its own next launch.
299
+ */
300
+ settingSources: ['user', 'project', 'local'],
107
301
  permissionMode: MODE_TO_PERMISSION[this.mode],
108
- systemPrompt: { type: 'preset', preset: 'claude_code', append: SYSTEM_APPEND },
302
+ systemPrompt: {
303
+ type: 'preset',
304
+ preset: 'claude_code',
305
+ append: composeSystemAppend(spec.workspaceContext),
306
+ },
109
307
  canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
308
+ // Ticket #113: a running subagent forks its own conversation every ~30s
309
+ // for a one-line «what am I doing right now», delivered as the `summary`
310
+ // of a `task_progress`. It reuses the subagent's model and prompt cache,
311
+ // so it is close to free — and without it a twenty-minute subagent is a
312
+ // spinner with no words next to it.
313
+ agentProgressSummaries: true,
110
314
  ...(spec.model ? { model: spec.model } : {}),
315
+ // Only the five levels the SDK declares. A value from an older pin, or
316
+ // from a model that has since been switched away from, must not reach the
317
+ // CLI — it rejects the whole query rather than the one option.
318
+ ...(isEffortLevel(spec.effort) ? { effort: spec.effort } : {}),
319
+ // `ultracode` is a settings flag, not a level, so it travels in the
320
+ // inline settings layer instead — the launch-time twin of the
321
+ // `applyFlagSettings` call `setEffort` makes.
322
+ ...(spec.effort === ULTRACODE ? { settings: { ultracode: true } } : {}),
111
323
  ...(spec.resumeProviderSessionId ? { resume: spec.resumeProviderSessionId } : {}),
112
324
  ...(spec.maxBudgetUsd !== undefined ? { maxBudgetUsd: spec.maxBudgetUsd } : {}),
113
- ...(spec.mcp
114
- ? {
115
- mcpServers: {
116
- devbridge: {
117
- type: 'http',
118
- url: spec.mcp.url,
119
- headers: { Authorization: `Bearer ${spec.mcp.token}` },
120
- },
121
- },
122
- }
123
- : {}),
325
+ // Ticket #119. NOT `mcpServers` — the SDK JSON-stringifies that option
326
+ // straight into argv (`sdk.mjs`: `H.push("--mcp-config", Re({mcpServers:ke}))`),
327
+ // so the project's live `dbk_…` key landed in `/proc/<pid>/cmdline`,
328
+ // readable by every process on the box. That is how it was found: an
329
+ // agent ran `ps` for an unrelated reason and its own key scrolled past.
330
+ //
331
+ // The CLI accepts a PATH in the same flag — `--mcp-config <configs...>`,
332
+ // "Load MCP servers from JSON files or strings" — and `extraArgs` is the
333
+ // SDK's public escape hatch for passing one. Same JSON, same merge
334
+ // semantics (no `--strict-mcp-config`, so a project's own `.mcp.json`
335
+ // still loads exactly as before); only argv changes.
336
+ //
337
+ // The file is 0600 AND named into both secret lists in `policy.ts`. The
338
+ // second half is the load-bearing one: the agent runs as the same uid, so
339
+ // file mode alone would stop nobody.
340
+ ...(mcpConfigFile
341
+ ? { extraArgs: { 'mcp-config': mcpConfigFile } }
342
+ : mcpServers
343
+ ? { mcpServers }
344
+ : {}),
124
345
  };
125
346
  this.q = queryFn({ prompt: this.input, options });
126
347
  void this.consume();
348
+ // Ticket #113. The task level is per-PROCESS: the SDK emits nothing at
349
+ // startup and tells consumers to reset to the empty set whenever the
350
+ // session's CLI process (re)starts. The dashboard reads the newest
351
+ // `agent_tasks` in the stored feed, so without this a session killed
352
+ // mid-turn — the runner restarted, the machine rebooted — would come back
353
+ // and show the subagents of its previous life as though they were still
354
+ // running, right up until the next membership change.
355
+ // Ticket #119 / QA-114 MAJOR-3: if the protected file could not be written,
356
+ // say so where a person will see it, not only in journald.
357
+ if (this.mcpFallbackNotice) {
358
+ this.emit({ type: 'notice', level: 'warn', text: this.mcpFallbackNotice });
359
+ }
360
+ this.lastTaskFingerprint = JSON.stringify({ done: 0, total: 0, tasks: [] });
361
+ this.emit({ type: 'agent_tasks', tasks: [], done: 0, total: 0 });
127
362
  // Report what the agent can do right away. `system:init` only arrives with
128
363
  // the first turn (verified live), so a free session waiting for its first
129
364
  // message would otherwise show no model list at all — while the control
130
365
  // requests themselves work as soon as the CLI is up.
131
366
  this.refreshCapabilities();
132
367
  }
368
+ /**
369
+ * Write this session's MCP config to a 0600 file and return its path, or null
370
+ * if anything went wrong (the caller then keeps the old inline shape).
371
+ *
372
+ * `wx` after an unlink, not a plain write: the mode argument only applies when
373
+ * the file is CREATED, so writing over an existing one would silently inherit
374
+ * whatever permissions it already had. A resumed session hits exactly that
375
+ * path, because the name is derived from the session id.
376
+ */
377
+ writeMcpConfig(mcpServers) {
378
+ const target = mcpConfigPath(this.spec.sessionId);
379
+ try {
380
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
381
+ // mkdir's mode is subject to umask, and the directory may predate us.
382
+ fs.chmodSync(path.dirname(target), 0o700);
383
+ try {
384
+ fs.unlinkSync(target);
385
+ }
386
+ catch {
387
+ // Not there — the normal case.
388
+ }
389
+ fs.writeFileSync(target, JSON.stringify({ mcpServers }), { mode: 0o600, flag: 'wx' });
390
+ return target;
391
+ }
392
+ catch (error) {
393
+ log.warn('claude: MCP config file failed — falling back to inline argv', {
394
+ sessionId: this.spec.sessionId,
395
+ error: String(error),
396
+ });
397
+ // Not only `log.warn`. This path is reachable by the very attacker the
398
+ // fix defends against — anyone who can make the directory unwritable
399
+ // downgrades every later session on the machine back to a key in argv,
400
+ // permanently and silently (QA-114 MAJOR-3). A line in journald is not
401
+ // where a human would look. The notice goes into the session feed, where
402
+ // the events are already masked, so the text carries no secret itself.
403
+ this.mcpFallbackNotice =
404
+ 'MCP settings could not be written to a protected file, so this session passes its key on the command line — readable by other processes on this server. Sessions started before runner 0.22.1 always did this. Check that the runner state directory is writable.';
405
+ return null;
406
+ }
407
+ }
408
+ /** Best-effort removal of the file written by `writeMcpConfig`. */
409
+ removeMcpConfig() {
410
+ if (!this.mcpConfigFile)
411
+ return;
412
+ try {
413
+ fs.unlinkSync(this.mcpConfigFile);
414
+ }
415
+ catch {
416
+ // Already gone, or never made it to disk.
417
+ }
418
+ this.mcpConfigFile = null;
419
+ }
133
420
  pushUserText(text) {
134
421
  this.input.push({
135
422
  type: 'user',
@@ -206,15 +493,73 @@ class ClaudeSession {
206
493
  this.q.accountInfo().catch(() => null),
207
494
  this.q.mcpServerStatus().catch(() => []),
208
495
  ]);
209
- const capabilities = {
210
- // `default` is the alias row the CLI itself resolves to the account's
211
- // recommended model — this SDK version has no isDefault flag.
212
- models: models.map((m) => ({
496
+ // The CLI has answered a control request, so it is up and has already
497
+ // parsed --mcp-config. The file has no further job: verified live — after
498
+ // `unlink` the server stays `connected` with all 29 tools, because the
499
+ // config lives in the CLI's memory from then on.
500
+ //
501
+ // This is the difference between the key sitting on disk for the whole
502
+ // session and sitting there for about a second (QA-114 MINOR-6). The policy
503
+ // entries still matter, but what they guard is now a one-second window
504
+ // rather than a parked REVIEW session that lasts days.
505
+ this.removeMcpConfig();
506
+ // Remember the catalogue before publishing it: `system:init` resolves a
507
+ // wire id against this list, and it may land before the next probe.
508
+ if (models.length) {
509
+ this.knownModels = models.map((m) => ({
213
510
  id: m.value,
214
511
  label: m.displayName,
215
512
  ...(m.description ? { description: truncate(m.description, 160) } : {}),
513
+ // `default` is the alias row the CLI itself resolves to the account's
514
+ // recommended model — this SDK version has no isDefault flag.
216
515
  ...(m.value === 'default' ? { isDefault: true } : {}),
217
- })),
516
+ // Ticket #111: what `system:init` will call this row once a turn starts.
517
+ ...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}),
518
+ // Ticket #111: the levels THIS model accepts, straight from the CLI —
519
+ // not a list we keep in sync by hand. A model with no dial reports
520
+ // none, and the dashboard then draws no control for it.
521
+ ...(m.supportsEffort && m.supportedEffortLevels?.length
522
+ ? {
523
+ efforts: [
524
+ ...m.supportedEffortLevels.map((id) => ({
525
+ id,
526
+ label: EFFORT_LABELS[id] ?? id,
527
+ })),
528
+ // The sixth rung the CLI's `/effort` offers, and the one that
529
+ // is not a level. Gated on `xhigh` because that is what the SDK
530
+ // requires of it — a model without xhigh cannot run ultracode,
531
+ // and offering it there would be a rung that fails on use.
532
+ ...(m.supportedEffortLevels.includes('xhigh')
533
+ ? [
534
+ {
535
+ id: ULTRACODE,
536
+ label: EFFORT_LABELS[ULTRACODE] ?? ULTRACODE,
537
+ ...(EFFORT_DESCRIPTIONS[ULTRACODE]
538
+ ? { description: EFFORT_DESCRIPTIONS[ULTRACODE] }
539
+ : {}),
540
+ },
541
+ ]
542
+ : []),
543
+ ],
544
+ ...(m.supportedEffortLevels.includes('high') ? { defaultEffort: 'high' } : {}),
545
+ }
546
+ : {}),
547
+ }));
548
+ }
549
+ // The catalogue may have arrived AFTER the id it explains: `system:init`
550
+ // can land first, or the probe can have failed the first time, and it does
551
+ // not come round again. Re-ask now that there is something to ask.
552
+ //
553
+ // With the RAW id when the CLI has reported one (QA-111 M3) — asking with
554
+ // `this.model` there would turn the question «does the pick still resolve
555
+ // to what is running» into «which row owns this wire id», and two rows can
556
+ // own it. Falling back to `this.model` is safe and covers the other half:
557
+ // a session whose stored column already holds a wire id, from before any
558
+ // of this existed. If it IS a catalogue row, `adoptLiveModel` recognises
559
+ // it and returns without touching anything.
560
+ this.adoptLiveModel(this.liveWireModel ?? this.model);
561
+ const capabilities = {
562
+ models: this.knownModels,
218
563
  modes: [...AGENT_MODES],
219
564
  // Cap the list: it lands in an event payload with a hard size limit.
220
565
  commands: commands.slice(0, 150).map((c) => ({
@@ -224,6 +569,7 @@ class ClaudeSession {
224
569
  })),
225
570
  currentMode: this.mode,
226
571
  ...(this.model ? { currentModel: this.model } : {}),
572
+ ...(this.effort ? { currentEffort: this.effort } : {}),
227
573
  ...(account
228
574
  ? {
229
575
  account: {
@@ -238,6 +584,50 @@ class ClaudeSession {
238
584
  };
239
585
  this.emit({ type: 'capabilities', capabilities });
240
586
  }
587
+ /**
588
+ * Take what `system:init` says the live model is, WITHOUT losing the alias
589
+ * the user picked (ticket #111).
590
+ *
591
+ * The catalogue is written in aliases (`default`, `sonnet`, `opus[1m]`) and
592
+ * `system:init` answers with the resolved wire id (`claude-opus-5[1m]`).
593
+ * Storing the wire id verbatim — which is what this adapter used to do — left
594
+ * `currentModel` pointing at a row that does not exist in the list the
595
+ * dashboard was given. Two things broke at once: the model picker fell back
596
+ * to printing the raw id, and the effort picker, which reads its levels off
597
+ * the SELECTED row, found no row and drew nothing at all.
598
+ *
599
+ * So: if the reported id resolves to the row we already hold, keep the alias.
600
+ * If it resolves to a DIFFERENT row (a genuine fallback), adopt that row's
601
+ * alias. Only an id no row claims is stored as-is — it is still the honest
602
+ * answer to «what is running», and it is better than naming the wrong model.
603
+ */
604
+ adoptLiveModel(reported) {
605
+ if (!reported)
606
+ return;
607
+ this.liveWireModel = reported;
608
+ // Nothing to resolve against yet (QA-111 M3). Keep the pick — and the raw
609
+ // id — and come back to this the moment the catalogue lands. Overwriting
610
+ // `this.model` here is what lost the alias, and it never came back: the
611
+ // next attempt had only the wire id to ask with, so it asked the catalogue
612
+ // instead of the pick and was answered `default`.
613
+ if (this.knownModels.length === 0)
614
+ return;
615
+ // The row the user's own pick names, when the pick IS a catalogue row.
616
+ const picked = this.knownModels.find((m) => m.id === this.model);
617
+ // Two rows can resolve to the same wire model (`default` and `opus[1m]`
618
+ // both did on the machine this was traced on), so «does the pick resolve to
619
+ // what is running» has to be asked of the PICK — asking the catalogue which
620
+ // row owns the wire id would answer `default` and quietly move the picker
621
+ // off the alias the user chose.
622
+ if (picked && (picked.id === reported || picked.resolvedModel === reported))
623
+ return;
624
+ const live = this.knownModels.find((m) => m.id === reported || m.resolvedModel === reported);
625
+ const next = live?.id ?? reported;
626
+ if (next === this.model)
627
+ return;
628
+ this.model = next;
629
+ this.refreshCapabilities();
630
+ }
241
631
  async setModel(model) {
242
632
  await this.q.setModel(model);
243
633
  this.model = model;
@@ -253,27 +643,356 @@ class ClaudeSession {
253
643
  this.mode = mode;
254
644
  this.emit({ type: 'settings', mode });
255
645
  }
256
- async setEffort(_effort) {
257
- // Claude Code has no per-model reasoning dial: its models advertise no
258
- // `efforts`, so the dashboard never shows the control for this agent.
646
+ /**
647
+ * Session 15: the project's trust level and auto-commit switch, changed
648
+ * while this session runs. `this.spec` is what `evaluateToolUse` reads on
649
+ * every tool call, so the next one already sees it.
650
+ */
651
+ setWorkspacePolicy(policy) {
652
+ if (policy.trustMode !== undefined)
653
+ this.spec.trustMode = policy.trustMode;
654
+ if (policy.agentAutoCommit !== undefined)
655
+ this.spec.agentAutoCommit = policy.agentAutoCommit;
656
+ }
657
+ /**
658
+ * Change the reasoning-effort level (tickets #111, #112).
659
+ *
660
+ * `effort` is a query OPTION, so this adapter used to record the pin and tell
661
+ * the user it would apply «from the next start of this session». That was
662
+ * never true of the CLI, only of us: `applyFlagSettings` merges into the flag
663
+ * settings layer mid-session and the NEXT turn runs at the new level. Verified
664
+ * live against `CLAUDE_EFFORT`, which the CLI exports into every Bash call:
665
+ * launched at `high` → `EFFORT=[high]`, after `applyFlagSettings({effortLevel:
666
+ * 'low'})` → `EFFORT=[low]`, same process.
667
+ *
668
+ * The launch option stays too — it is what a resumed session starts from, and
669
+ * what the pin means before the first turn.
670
+ */
671
+ async setEffort(effort) {
672
+ if (effort === null) {
673
+ delete this.effort;
674
+ }
675
+ else if (isEffortPin(effort)) {
676
+ this.effort = effort;
677
+ }
678
+ else {
679
+ // An unknown level would be rejected by the CLI at launch and take the
680
+ // whole session with it. Refuse it here, where nothing is lost.
681
+ return;
682
+ }
683
+ // An older CLI has no flag-settings channel. Absence is not a failure: the
684
+ // pin still holds for the next launch, and the notice has to say so rather
685
+ // than promise a change that will not happen until then.
686
+ let live = false;
687
+ if (typeof this.q.applyFlagSettings === 'function') {
688
+ try {
689
+ // BOTH keys, every time. `ultracode` is a separate flag that forces
690
+ // xhigh, so choosing a plain level without clearing it would leave
691
+ // every later turn at xhigh while the picker showed something else —
692
+ // and choosing ultracode without clearing `effortLevel` would leave a
693
+ // level underneath it. `null` on either clears it from the flag layer
694
+ // and falls back to the model's own default, which is what «Model
695
+ // default» means here.
696
+ await this.q.applyFlagSettings({
697
+ ultracode: this.effort === ULTRACODE,
698
+ effortLevel: isEffortLevel(this.effort) ? this.effort : null,
699
+ });
700
+ live = true;
701
+ }
702
+ catch (error) {
703
+ log.warn('claude: applyFlagSettings(effortLevel) failed', { error: String(error) });
704
+ }
705
+ }
706
+ this.emit({ type: 'settings', effort: this.effort ?? null });
707
+ const level = this.effort ? (EFFORT_LABELS[this.effort] ?? this.effort) : null;
708
+ this.emit({
709
+ type: 'notice',
710
+ level: 'info',
711
+ text: live
712
+ ? level
713
+ ? `Reasoning effort is «${level}» from the next turn.`
714
+ : 'Reasoning effort goes back to the model default from the next turn.'
715
+ : level
716
+ ? `Reasoning effort will be «${level}» from the next start of this session — this runner's Claude Code cannot change it on a running process.`
717
+ : 'Reasoning effort goes back to the model default from the next start of this session.',
718
+ });
719
+ this.refreshCapabilities();
720
+ }
721
+ // ─── Ticket #113: what is running beside the conversation ──────────
722
+ //
723
+ // Membership is owned by `background_tasks_changed` — a LEVEL signal the SDK
724
+ // documents with REPLACE semantics, precisely so a missed start/stop pair
725
+ // cannot wedge a task in the tray forever. The `task_*` bookends only fill in
726
+ // detail (what kind of work, how many tokens, how long) and count the turn.
727
+ onTaskMessage(msg) {
728
+ switch (msg.subtype) {
729
+ case 'background_tasks_changed': {
730
+ const rows = Array.isArray(msg['tasks']) ? msg['tasks'] : [];
731
+ this.liveTaskIds = [];
732
+ for (const row of rows) {
733
+ const id = typeof row['task_id'] === 'string' ? row['task_id'] : null;
734
+ if (!id)
735
+ continue;
736
+ // Housekeeping the SDK asks consumers to hide. Filtered HERE as well
737
+ // as in `task_started`, because the level usually arrives first — so
738
+ // filtering only the bookend let an ambient task into the live set
739
+ // and it never left again (QA-111 m4).
740
+ if (this.skipTaskIds.has(id))
741
+ continue;
742
+ this.liveTaskIds.push(id);
743
+ // A task can reach the live set before its own `task_started` — the
744
+ // SDK says the level usually precedes the bookends. Seed it here so
745
+ // the tray never has a live id it cannot name.
746
+ this.touchTask(id, {
747
+ kind: typeof row['task_type'] === 'string' ? row['task_type'] : 'task',
748
+ title: typeof row['description'] === 'string' ? row['description'] : 'Working…',
749
+ });
750
+ }
751
+ this.publishTasks();
752
+ break;
753
+ }
754
+ case 'task_started': {
755
+ const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
756
+ if (!id)
757
+ break;
758
+ // Housekeeping tasks the SDK asks consumers to keep out of the
759
+ // transcript stay out of the tray too — and out of the live set, which
760
+ // may already have taken it in before this message arrived.
761
+ if (msg['skip_transcript'] === true) {
762
+ this.skipTaskIds.add(id);
763
+ this.tasks.delete(id);
764
+ const before = this.liveTaskIds.length;
765
+ this.liveTaskIds = this.liveTaskIds.filter((live) => live !== id);
766
+ if (this.liveTaskIds.length !== before)
767
+ this.publishTasks();
768
+ break;
769
+ }
770
+ this.touchTask(id, {
771
+ kind: typeof msg['task_type'] === 'string' ? msg['task_type'] : 'task',
772
+ ...(typeof msg['description'] === 'string' ? { title: msg['description'] } : {}),
773
+ ...(typeof msg['subagent_type'] === 'string'
774
+ ? { subagentType: msg['subagent_type'] }
775
+ : {}),
776
+ ...(typeof msg['workflow_name'] === 'string'
777
+ ? { workflowName: msg['workflow_name'] }
778
+ : {}),
779
+ });
780
+ this.publishTasks();
781
+ break;
782
+ }
783
+ case 'task_progress': {
784
+ const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
785
+ if (!id)
786
+ break;
787
+ this.touchTask(id, {
788
+ ...(typeof msg['description'] === 'string' ? { title: msg['description'] } : {}),
789
+ ...(typeof msg['subagent_type'] === 'string'
790
+ ? { subagentType: msg['subagent_type'] }
791
+ : {}),
792
+ ...(typeof msg['summary'] === 'string' ? { summary: msg['summary'] } : {}),
793
+ ...usagePatch(msg['usage']),
794
+ });
795
+ this.publishTasks();
796
+ break;
797
+ }
798
+ case 'task_updated': {
799
+ const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
800
+ const patch = (msg['patch'] ?? {});
801
+ if (!id || !this.tasks.has(id))
802
+ break;
803
+ const status = typeof patch['status'] === 'string' ? patch['status'] : undefined;
804
+ this.touchTask(id, {
805
+ ...(typeof patch['description'] === 'string' ? { title: patch['description'] } : {}),
806
+ ...(status ? { status: taskStatus(status) } : {}),
807
+ });
808
+ this.publishTasks();
809
+ break;
810
+ }
811
+ case 'task_notification': {
812
+ const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
813
+ if (!id)
814
+ break;
815
+ this.touchTask(id, {
816
+ status: taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed'),
817
+ ...(typeof msg['summary'] === 'string' ? { summary: msg['summary'] } : {}),
818
+ ...usagePatch(msg['usage']),
819
+ });
820
+ this.publishTasks();
821
+ break;
822
+ }
823
+ default:
824
+ break;
825
+ }
826
+ }
827
+ /**
828
+ * Create-or-merge one task row, and count the TRANSITIONS while doing it.
829
+ *
830
+ * The counters live here rather than in the individual message handlers
831
+ * because «started» and «finished» are properties of the transition, not of
832
+ * whichever message happened to announce it (QA-111 M1). They were counted
833
+ * per-message, and both orderings the SDK actually produces missed:
834
+ *
835
+ * `background_tasks_changed` → `task_started` → `total` stayed 0
836
+ * `task_updated{completed}` → `task_notification` → `done` stayed 0
837
+ *
838
+ * The first of those is the COMMON ordering — the SDK documents the level as
839
+ * usually preceding the bookends — so «N of M done», the one number the
840
+ * ticket asked for by name, was never printed at all.
841
+ *
842
+ * `startedAt` is stamped once and never moves.
843
+ */
844
+ touchTask(id, patch) {
845
+ const existing = this.tasks.get(id);
846
+ if (existing) {
847
+ const wasRunning = existing.status === 'running';
848
+ Object.assign(existing, patch);
849
+ if (patch.title)
850
+ existing.title = truncate(patch.title, TASK_TITLE_LIMIT);
851
+ if (patch.summary)
852
+ existing.summary = truncate(patch.summary, TASK_TITLE_LIMIT);
853
+ // Counted on the edge out of `running`, so a task that settles twice — a
854
+ // `task_updated` and then its `task_notification`, or a notification
855
+ // redelivered after a reconnect — is still counted once.
856
+ if (wasRunning && existing.status !== 'running')
857
+ this.turnTasksDone += 1;
858
+ return;
859
+ }
860
+ this.turnTasksStarted += 1;
861
+ const status = patch.status ?? 'running';
862
+ // A row that arrives already finished (a notification for a task we never
863
+ // saw start) counts as both, or `done` could exceed nothing at all.
864
+ if (status !== 'running')
865
+ this.turnTasksDone += 1;
866
+ this.tasks.set(id, {
867
+ ...patch,
868
+ id,
869
+ kind: patch.kind ?? 'task',
870
+ title: truncate(patch.title ?? 'Working…', TASK_TITLE_LIMIT),
871
+ status,
872
+ startedAt: Date.now(),
873
+ // The only free-text field an agent writes with no length of its own:
874
+ // a subagent's closing `summary` runs to kilobytes, and twenty of them
875
+ // push the event past the payload cap, which replaces the WHOLE payload
876
+ // with `{truncated:true}` and blinks the tray out (QA-111 m1).
877
+ ...(patch.summary ? { summary: truncate(patch.summary, TASK_TITLE_LIMIT) } : {}),
878
+ });
879
+ }
880
+ /**
881
+ * Publish the tray, at most once every `TASK_PUBLISH_INTERVAL_MS`.
882
+ *
883
+ * Every publication is a stored row in the session feed, and a wide fan-out
884
+ * changes membership dozens of times a minute. The trailing timer matters as
885
+ * much as the floor: the LAST change in a burst is the one that says the work
886
+ * is over, and dropping it would leave the tray running forever.
887
+ */
888
+ publishTasks() {
889
+ if (this.stopped)
890
+ return;
891
+ const wait = TASK_PUBLISH_INTERVAL_MS - (Date.now() - this.taskPublishedAt);
892
+ if (wait > 0) {
893
+ if (!this.taskPublishTimer) {
894
+ this.taskPublishTimer = setTimeout(() => {
895
+ this.taskPublishTimer = null;
896
+ this.flushTasks();
897
+ }, wait);
898
+ this.taskPublishTimer.unref();
899
+ }
900
+ return;
901
+ }
902
+ this.flushTasks();
903
+ }
904
+ flushTasks() {
905
+ if (this.stopped)
906
+ return;
907
+ this.taskPublishedAt = Date.now();
908
+ const now = Date.now();
909
+ // Only what the agent still calls live. A task that finished keeps its row
910
+ // in `this.tasks` for the counters, but the tray is about NOW.
911
+ const tasks = this.liveTaskIds
912
+ .map((id) => this.tasks.get(id))
913
+ .filter((t) => Boolean(t))
914
+ .slice(0, TASK_LIST_CAP)
915
+ // How long it has been running, measured HERE (QA-111 m2). `startedAt` is
916
+ // this machine's clock and the browser's is a different one — subtracting
917
+ // across them put the dev server's clock skew straight into the number,
918
+ // so a host ten minutes behind showed «10m 03s» on a task one second old.
919
+ .map((task) => ({ ...task, ageMs: Math.max(0, now - task.startedAt) }));
920
+ const payload = {
921
+ type: 'agent_tasks',
922
+ tasks,
923
+ done: this.turnTasksDone,
924
+ total: this.turnTasksStarted,
925
+ };
926
+ // A frame that says exactly what the last one said is not worth a row in
927
+ // the session feed (QA-111 M4). `task_progress` fires every ~30s per
928
+ // running subagent and usually carries nothing new, and with twenty of
929
+ // them that alone is the throttle's whole budget. `ageMs` is excluded from
930
+ // the comparison on purpose — it changes every time by definition, and
931
+ // including it would make every frame unique and the check pointless.
932
+ const fingerprint = JSON.stringify({
933
+ done: payload.done,
934
+ total: payload.total,
935
+ tasks: tasks.map(({ ageMs: _ageMs, ...rest }) => rest),
936
+ });
937
+ if (fingerprint === this.lastTaskFingerprint)
938
+ return;
939
+ this.lastTaskFingerprint = fingerprint;
940
+ this.emit(payload);
941
+ }
942
+ /**
943
+ * The turn ended. Reset what belongs to the TURN — and only that.
944
+ *
945
+ * `done`/`total` are per-turn by definition and go back to zero. The live set
946
+ * does NOT: background work is precisely the work that outlives the turn that
947
+ * started it, which is the whole reason the ticket asks for it. Caught on
948
+ * production during this session's own verification — the agent ended its
949
+ * turn with `sleep 40` still running in the background and the tray, which
950
+ * cleared everything here, showed nothing at exactly the moment somebody was
951
+ * reading the answer and wondering whether the deploy had finished.
952
+ *
953
+ * Membership stays owned by `background_tasks_changed`, which reports the set
954
+ * emptying when it actually empties. Finished tasks are dropped here because
955
+ * only the live ids are ever rendered anyway, and keeping their rows would
956
+ * grow the map for the life of the session.
957
+ */
958
+ endTaskTurn() {
959
+ if (this.taskPublishTimer) {
960
+ clearTimeout(this.taskPublishTimer);
961
+ this.taskPublishTimer = null;
962
+ }
963
+ const live = new Set(this.liveTaskIds);
964
+ for (const id of [...this.tasks.keys()]) {
965
+ if (!live.has(id))
966
+ this.tasks.delete(id);
967
+ }
968
+ this.skipTaskIds.clear();
969
+ this.turnTasksDone = 0;
970
+ this.turnTasksStarted = 0;
971
+ // Straight through `flushTasks` rather than an empty frame of its own: the
972
+ // counters changed, so the fingerprint differs and it will publish — and
973
+ // what it publishes is the truth about what is still running.
974
+ this.taskPublishedAt = 0;
975
+ this.flushTasks();
259
976
  }
260
977
  async onCanUseTool(toolName, input, opts) {
261
- // The agent's interactive question tool assumes a terminal picker. There
262
- // is none here, and its raw JSON is unreadable in a permission card — turn
263
- // it into a proper question card and let the user answer in the chat.
978
+ // The agent's interactive question tool assumes a terminal picker. There is
979
+ // none here — so the call is PARKED and the dashboard becomes the picker.
980
+ //
981
+ // Until session 12 this returned `deny`, which the model reads exactly like
982
+ // a human pressing Escape: the turn was not blocked, so the agent carried on
983
+ // and decided for the user. Parking is what makes "the question waits"
984
+ // true; the answer goes back as `{behavior:'allow', updatedInput}` with the
985
+ // `answers` field the tool's own schema defines (live-verified, §3).
264
986
  if (toolName === 'AskUserQuestion') {
265
- const question = parseAskUserQuestion(input);
266
- if (question) {
267
- this.emit({
268
- type: 'question',
269
- text: question.text,
270
- ...(question.options.length ? { options: question.options } : {}),
271
- });
272
- return {
273
- behavior: 'deny',
274
- message: 'This session has no interactive picker. Ask the question in plain text and end your turn — the user answers with the next message.',
275
- };
987
+ const questions = parseAskUserQuestions(input);
988
+ if (questions.length) {
989
+ return this.parkQuestion(input, questions, opts.signal);
276
990
  }
991
+ // Nothing renderable: refuse rather than park a card no one can answer.
992
+ return {
993
+ behavior: 'deny',
994
+ message: 'That question could not be shown to the user (no question text). Ask again in plain text and end your turn.',
995
+ };
277
996
  }
278
997
  // Plan approval is a user decision by definition — never auto-resolved by
279
998
  // policy, and rendered as a plan card rather than a raw tool prompt.
@@ -291,6 +1010,9 @@ class ClaudeSession {
291
1010
  }
292
1011
  const verdict = evaluateToolUse(toolName, input, {
293
1012
  trustMode: this.spec.trustMode,
1013
+ ...(this.spec.agentAutoCommit === undefined
1014
+ ? {}
1015
+ : { agentAutoCommit: this.spec.agentAutoCommit }),
294
1016
  worktreePath: this.spec.cwd,
295
1017
  });
296
1018
  if (verdict.decision === 'allow') {
@@ -339,6 +1061,158 @@ class ClaudeSession {
339
1061
  }, { once: true });
340
1062
  });
341
1063
  }
1064
+ // ─── Questions (session 12) ────────────────────────────────────────
1065
+ /**
1066
+ * Hold the tool call open until a human answers it.
1067
+ *
1068
+ * The promise is the whole mechanism: while it is unresolved the agent's turn
1069
+ * cannot advance, so "the user went away for 40 minutes" costs the session
1070
+ * nothing but time on the clock — which the supervisor stops.
1071
+ */
1072
+ parkQuestion(input, questions, signal) {
1073
+ const askId = newAskId();
1074
+ const rawTextById = new Map();
1075
+ const rawQuestions = Array.isArray(input['questions']) ? input['questions'] : [];
1076
+ for (const question of questions) {
1077
+ // Indexed by the id, which `parseAskUserQuestions` derives from the RAW
1078
+ // position — walking the parsed list with its own counter shifted every
1079
+ // mapping as soon as one raw entry was skipped, and the answer would then
1080
+ // be keyed by another question's text or by the truncated copy, which is
1081
+ // exactly the byte-mismatch this design exists to avoid (QA-106 M3).
1082
+ const rawIndex = Number.parseInt(question.id.slice(1), 10);
1083
+ const raw = Number.isInteger(rawIndex)
1084
+ ? rawQuestions[rawIndex]
1085
+ : undefined;
1086
+ const rawText = typeof raw?.['question'] === 'string' ? raw['question'] : question.text;
1087
+ rawTextById.set(question.id, rawText);
1088
+ }
1089
+ return new Promise((resolve) => {
1090
+ this.pendingQuestions.set(askId, { input, questions, rawTextById, resolve });
1091
+ // Abort fires when the turn is torn down under us (interrupt, process
1092
+ // shutdown). Say so in the feed — a card that simply stops working is the
1093
+ // thing this whole session set out to remove.
1094
+ signal.addEventListener('abort', () => this.invalidateQuestion(askId, 'turn_aborted'), {
1095
+ once: true,
1096
+ });
1097
+ this.emit({
1098
+ type: 'question',
1099
+ askId,
1100
+ questions,
1101
+ // Mirror fields for a dashboard/API that has not been redeployed yet.
1102
+ text: questions[0]?.text ?? '',
1103
+ ...(mirrorOptions(questions).length ? { options: mirrorOptions(questions) } : {}),
1104
+ });
1105
+ });
1106
+ }
1107
+ answerQuestion(reply) {
1108
+ const pending = this.pendingQuestions.get(reply.askId);
1109
+ if (!pending) {
1110
+ log.warn('claude: answer for a question that is no longer open', { askId: reply.askId });
1111
+ return false;
1112
+ }
1113
+ // Validated BEFORE the ask is consumed: an answer with nothing in it would
1114
+ // otherwise release the tool call with `answers: {}` — the very shape of
1115
+ // the auto-answer this session deleted — and leave the card unanswerable.
1116
+ if (reply.action === 'answer' && !hasAnyValue(reply.answers, pending.rawTextById)) {
1117
+ log.warn('claude: refusing an empty answer', { askId: reply.askId });
1118
+ return false;
1119
+ }
1120
+ this.pendingQuestions.delete(reply.askId);
1121
+ if (reply.action === 'discuss') {
1122
+ const text = reply.text ?? '';
1123
+ this.emit({
1124
+ type: 'question_resolved',
1125
+ askId: reply.askId,
1126
+ outcome: 'discussed',
1127
+ source: 'user',
1128
+ summary: truncate(text.trim(), 200),
1129
+ });
1130
+ // A deny WITH a message keeps the turn alive and hands the words to the
1131
+ // agent (live-verified §3). A deny without one would end it.
1132
+ pending.resolve({ behavior: 'deny', message: discussMessage(pending.questions, text) });
1133
+ return true;
1134
+ }
1135
+ const answers = (reply.answers ?? []).filter((answer) => pending.rawTextById.has(answer.questionId));
1136
+ const answersByText = {};
1137
+ const annotations = {};
1138
+ for (const answer of answers) {
1139
+ const rawText = pending.rawTextById.get(answer.questionId);
1140
+ if (!rawText)
1141
+ continue;
1142
+ const value = answerValue(answer);
1143
+ // An empty value is not an answer — leaving the key out is honest, and
1144
+ // the agent sees which questions are still open.
1145
+ if (value)
1146
+ answersByText[rawText] = value;
1147
+ const notes = answer.notes?.trim();
1148
+ if (notes)
1149
+ annotations[rawText] = { notes: truncate(notes, 1_000) };
1150
+ }
1151
+ this.emit({
1152
+ type: 'question_resolved',
1153
+ askId: reply.askId,
1154
+ outcome: 'answered',
1155
+ source: 'user',
1156
+ answers,
1157
+ summary: answerSummary(answers),
1158
+ });
1159
+ // Not every question answered. The dashboard makes this unreachable, but a
1160
+ // direct API call can do it — and releasing the tool call with the missing
1161
+ // ones simply absent means the agent decides them itself, which is the one
1162
+ // thing this whole session exists to prevent. Hand the answers back as
1163
+ // TEXT instead, naming what is still open, and let it ask again.
1164
+ const unanswered = pending.questions.filter((question) => !(pending.rawTextById.get(question.id) ?? '') ||
1165
+ !answersByText[pending.rawTextById.get(question.id) ?? '']);
1166
+ if (unanswered.length > 0) {
1167
+ pending.resolve({
1168
+ behavior: 'deny',
1169
+ message: [
1170
+ 'The user answered only some of the questions:',
1171
+ ...Object.entries(answersByText).map(([question, value]) => `- "${question}" → ${value}`),
1172
+ `Still unanswered: ${unanswered.map((question) => `"${question.text}"`).join('; ')}.`,
1173
+ 'Do not answer those yourself — ask again if you still need them.',
1174
+ ].join('\n'),
1175
+ });
1176
+ return true;
1177
+ }
1178
+ pending.resolve({
1179
+ behavior: 'allow',
1180
+ // The ORIGINAL input, untouched apart from the answers: the tool input is
1181
+ // validated strictly, and a truncated question text would not match its
1182
+ // own `answers` key (§3 risk 1). `answers`/`annotations` are declared
1183
+ // optional on `AskUserQuestionInput` itself and were verified live (§3).
1184
+ updatedInput: {
1185
+ ...pending.input,
1186
+ answers: answersByText,
1187
+ ...(Object.keys(annotations).length ? { annotations } : {}),
1188
+ },
1189
+ });
1190
+ return true;
1191
+ }
1192
+ cancelQuestions(reason) {
1193
+ for (const askId of [...this.pendingQuestions.keys()]) {
1194
+ this.invalidateQuestion(askId, reason);
1195
+ }
1196
+ }
1197
+ /** Withdraw an open question with a reason the user can read. */
1198
+ invalidateQuestion(askId, reason) {
1199
+ const pending = this.pendingQuestions.get(askId);
1200
+ if (!pending)
1201
+ return;
1202
+ this.pendingQuestions.delete(askId);
1203
+ this.emit({
1204
+ type: 'question_resolved',
1205
+ askId,
1206
+ outcome: 'invalidated',
1207
+ source: 'runner',
1208
+ reason,
1209
+ });
1210
+ pending.resolve({ behavior: 'deny', message: invalidationMessage(reason) });
1211
+ }
1212
+ /** The oldest ask still waiting — where a plain typed message lands. */
1213
+ oldestOpenAsk() {
1214
+ return this.pendingQuestions.keys().next().value;
1215
+ }
342
1216
  answerPermission(requestId, allow, note) {
343
1217
  const pending = this.pending.get(requestId);
344
1218
  if (!pending) {
@@ -363,6 +1237,15 @@ class ClaudeSession {
363
1237
  }
364
1238
  }
365
1239
  send(text) {
1240
+ // A parked question blocks the turn, so a plain message — a Telegram reply,
1241
+ // an older dashboard, the composer's own fallback — would sit unread in the
1242
+ // input queue until the card was answered. Route it into the «discuss»
1243
+ // exit: the words reach the agent immediately and the turn continues.
1244
+ const openAsk = this.oldestOpenAsk();
1245
+ if (openAsk) {
1246
+ this.answerQuestion({ askId: openAsk, action: 'discuss', text });
1247
+ return;
1248
+ }
366
1249
  const accepted = this.input.push({
367
1250
  type: 'user',
368
1251
  message: { role: 'user', content: text },
@@ -382,10 +1265,32 @@ class ClaudeSession {
382
1265
  log.warn('claude: interrupt failed', { error: String(error) });
383
1266
  }
384
1267
  }
385
- stop() {
1268
+ stop(reason = 'session_stopped') {
386
1269
  if (this.stopped)
387
1270
  return;
388
1271
  this.stopped = true;
1272
+ // A pending tray publication would fire into a closed output queue.
1273
+ if (this.taskPublishTimer) {
1274
+ clearTimeout(this.taskPublishTimer);
1275
+ this.taskPublishTimer = null;
1276
+ }
1277
+ // Before the process goes: withdraw everything a human was still being
1278
+ // asked, WITH a cause. A pending permission used to gutter out as a plain
1279
+ // "denied", which put a decision in the audit trail that nobody made.
1280
+ for (const askId of [...this.pendingQuestions.keys()]) {
1281
+ this.invalidateQuestion(askId, reason);
1282
+ }
1283
+ for (const [requestId, pending] of [...this.pending]) {
1284
+ this.pending.delete(requestId);
1285
+ this.emit({
1286
+ type: 'permission_resolved',
1287
+ requestId,
1288
+ allow: false,
1289
+ source: 'runner',
1290
+ reason: invalidationMessage(reason),
1291
+ });
1292
+ pending.resolve({ behavior: 'deny', message: invalidationMessage(reason) });
1293
+ }
389
1294
  this.input.end();
390
1295
  try {
391
1296
  this.q.close();
@@ -393,6 +1298,9 @@ class ClaudeSession {
393
1298
  catch {
394
1299
  // process already gone
395
1300
  }
1301
+ // After close, not before: the CLI reads --mcp-config at startup, but a
1302
+ // reconnecting MCP transport could touch it again while the process lives.
1303
+ this.removeMcpConfig();
396
1304
  }
397
1305
  async consume() {
398
1306
  try {
@@ -407,10 +1315,7 @@ class ClaudeSession {
407
1315
  });
408
1316
  // The live model can differ from what we asked for (alias
409
1317
  // resolution, fallback) — refresh the pickers when it does.
410
- if (msg.model && msg.model !== this.model) {
411
- this.model = msg.model;
412
- this.refreshCapabilities();
413
- }
1318
+ this.adoptLiveModel(msg.model);
414
1319
  }
415
1320
  else if (msg.subtype === 'status') {
416
1321
  const status = msg.status;
@@ -428,6 +1333,10 @@ class ClaudeSession {
428
1333
  });
429
1334
  }
430
1335
  }
1336
+ else {
1337
+ // Ticket #113: subagents, background shells and dynamic workflows.
1338
+ this.onTaskMessage(msg);
1339
+ }
431
1340
  break;
432
1341
  }
433
1342
  case 'conversation_reset': {
@@ -478,6 +1387,11 @@ class ClaudeSession {
478
1387
  numTurns: msg.num_turns,
479
1388
  durationMs: msg.duration_ms,
480
1389
  });
1390
+ // The turn is over: whatever the tray still shows as running is
1391
+ // finished or gone with it. Published immediately rather than on
1392
+ // the throttle — a stale «12 agents running» outlives the spinner
1393
+ // otherwise, and it is the last thing anybody sees.
1394
+ this.endTaskTurn();
481
1395
  this.refreshContextUsage();
482
1396
  if (msg.subtype === 'success') {
483
1397
  this.emit({ type: 'turn_end', ok: true });
@@ -506,6 +1420,13 @@ class ClaudeSession {
506
1420
  });
507
1421
  }
508
1422
  finally {
1423
+ // BEFORE `stopped`, not after: `stop()` returns early when it is already
1424
+ // set, so cleanup placed only there never ran on the commonest ending —
1425
+ // the agent process exiting on its own (QA-114 MAJOR-1). Normally the
1426
+ // file is long gone by now (removed in `publishCapabilities`); this is
1427
+ // the path that catches a session which died before the CLI ever
1428
+ // answered.
1429
+ this.removeMcpConfig();
509
1430
  this.stopped = true;
510
1431
  this.input.end();
511
1432
  this.output.end();
@@ -528,35 +1449,68 @@ function truncateDeep(value, limit) {
528
1449
  }
529
1450
  return value;
530
1451
  }
1452
+ /** Does this reply carry at least one real value for a question we asked? */
1453
+ function hasAnyValue(answers, known) {
1454
+ return (answers ?? []).some((answer) => known.has(answer.questionId) && answerValue(answer).length > 0);
1455
+ }
531
1456
  /**
532
- * Flatten the AskUserQuestion payload into plain text + option labels.
533
- * Shape: `{ questions: [{ question, header, options: [{ label, description }] }] }`.
1457
+ * The whole `AskUserQuestion` payload, not just its first line.
1458
+ *
1459
+ * Shape (SDK 0.3.218 `AskUserQuestionInput`): up to four
1460
+ * `{ question, header, multiSelect, options: [{ label, description, preview }] }`.
1461
+ * Before session 12 we kept `questions[0]` and glued `label — description` into
1462
+ * one string, so questions 2–4, every description, `header`, `multiSelect` and
1463
+ * `preview` were dropped on the floor.
1464
+ *
1465
+ * `allowsCustom` is always true here: the tool's own schema says an "Other"
1466
+ * option is the client's job ("There should be no 'Other' option, that will be
1467
+ * provided automatically"), and we are the client.
534
1468
  */
535
- export function parseAskUserQuestion(input) {
536
- const questions = input['questions'];
537
- const first = Array.isArray(questions) ? questions[0] : undefined;
538
- if (!first || typeof first !== 'object')
539
- return null;
540
- const row = first;
541
- const text = typeof row['question'] === 'string' ? row['question'] : null;
542
- if (!text)
543
- return null;
544
- const rawOptions = Array.isArray(row['options']) ? row['options'] : [];
545
- const options = rawOptions
546
- .map((option) => {
547
- if (typeof option === 'string')
548
- return option;
549
- const entry = option;
550
- const label = typeof entry['label'] === 'string' ? entry['label'] : null;
551
- if (!label)
552
- return null;
553
- const description = typeof entry['description'] === 'string' ? entry['description'] : undefined;
554
- return description ? `${label} — ${description}` : label;
555
- })
556
- .filter((option) => Boolean(option))
557
- .map((option) => truncate(option, 300))
558
- .slice(0, 8);
559
- return { text: truncate(text, 2_000), options };
1469
+ export function parseAskUserQuestions(input) {
1470
+ const raw = Array.isArray(input['questions']) ? input['questions'] : [];
1471
+ const questions = [];
1472
+ raw.slice(0, MAX_QUESTIONS).forEach((entry, index) => {
1473
+ if (!entry || typeof entry !== 'object')
1474
+ return;
1475
+ const row = entry;
1476
+ const text = typeof row['question'] === 'string' ? row['question'] : '';
1477
+ if (!text.trim())
1478
+ return;
1479
+ const header = typeof row['header'] === 'string' ? row['header'].trim() : '';
1480
+ const rawOptions = Array.isArray(row['options']) ? row['options'] : [];
1481
+ const options = rawOptions
1482
+ .map((option) => {
1483
+ if (typeof option === 'string') {
1484
+ return option.trim() ? { label: truncate(option, OPTION_TEXT_LIMIT) } : null;
1485
+ }
1486
+ if (!option || typeof option !== 'object')
1487
+ return null;
1488
+ const entryRow = option;
1489
+ const label = typeof entryRow['label'] === 'string' ? entryRow['label'] : '';
1490
+ if (!label.trim())
1491
+ return null;
1492
+ const description = typeof entryRow['description'] === 'string' ? entryRow['description'] : '';
1493
+ const preview = typeof entryRow['preview'] === 'string' ? entryRow['preview'] : '';
1494
+ return {
1495
+ label: truncate(label, OPTION_TEXT_LIMIT),
1496
+ ...(description ? { description: truncate(description, OPTION_TEXT_LIMIT) } : {}),
1497
+ ...(preview ? { preview: truncate(preview, 2_000) } : {}),
1498
+ };
1499
+ })
1500
+ .filter((option) => option !== null)
1501
+ .slice(0, MAX_OPTIONS);
1502
+ questions.push({
1503
+ id: `q${index}`,
1504
+ text: truncate(text, QUESTION_TEXT_LIMIT),
1505
+ // 24 rather than the tool's own 12: a header is a chip, and clipping a
1506
+ // slightly long one is better than dropping it.
1507
+ ...(header ? { header: header.slice(0, 24) } : {}),
1508
+ multiSelect: row['multiSelect'] === true,
1509
+ allowsCustom: true,
1510
+ options,
1511
+ });
1512
+ });
1513
+ return questions;
560
1514
  }
561
1515
  function truncateInput(input) {
562
1516
  return truncateDeep(input, 2_000);