@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.
- package/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +402 -4
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +7 -0
- package/dist/self-update.js +28 -1
- package/dist/service-unit.d.ts +48 -1
- package/dist/service-unit.js +109 -4
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
//
|
|
263
|
-
//
|
|
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
|
|
266
|
-
if (
|
|
267
|
-
this.
|
|
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
|
-
|
|
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
|
-
*
|
|
533
|
-
*
|
|
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
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
.
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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);
|