@bridge4dev/runner 0.27.0 → 0.30.0
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.js +391 -16
- package/dist/adapters/codex.js +187 -6
- package/dist/adapters/types.d.ts +115 -4
- package/dist/adapters/types.js +31 -0
- package/dist/attachments.d.ts +27 -0
- package/dist/attachments.js +150 -8
- package/dist/checkpoints.d.ts +175 -0
- package/dist/checkpoints.js +816 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -0
- package/dist/index.js +30 -0
- package/dist/journal.d.ts +34 -1
- package/dist/journal.js +51 -2
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +12 -0
- package/dist/policy.d.ts +40 -0
- package/dist/policy.js +60 -6
- package/dist/protocol.d.ts +5 -5
- package/dist/supervisor.d.ts +90 -0
- package/dist/supervisor.js +692 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -5,7 +5,7 @@ import { AsyncQueue } from '../async-queue.js';
|
|
|
5
5
|
import { log } from '../log.js';
|
|
6
6
|
import { mcpConfigPath } from '../paths.js';
|
|
7
7
|
import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
|
|
8
|
-
import {
|
|
8
|
+
import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
|
|
9
9
|
import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
|
|
10
10
|
// Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
|
|
11
11
|
// 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
|
|
@@ -65,14 +65,77 @@ export function scrubbedEnv() {
|
|
|
65
65
|
}
|
|
66
66
|
return env;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The one env var the allowlist above refuses, added back for exactly one mode
|
|
70
|
+
* (ticket #156).
|
|
71
|
+
*
|
|
72
|
+
* The CLI's own guard, read out of the binary verbatim:
|
|
73
|
+
*
|
|
74
|
+
* ```js
|
|
75
|
+
* if (t === "bypassPermissions" || r) {
|
|
76
|
+
* if (typeof process.getuid === "function" && process.getuid() === 0
|
|
77
|
+
* && process.env.IS_SANDBOX !== "1" && !Z.CLAUDE_CODE_BUBBLEWRAP)
|
|
78
|
+
* console.error("--dangerously-skip-permissions cannot be used with root/sudo privileges …"),
|
|
79
|
+
* process.exit(1)
|
|
80
|
+
* }
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* The runner's default install is root (`install.sh`), so on an ordinary
|
|
84
|
+
* machine «Unrestricted» did not degrade — it killed the session at launch with
|
|
85
|
+
* exit code 1. On the machine this was found on it happened to start, because
|
|
86
|
+
* `~/.claude/settings.json` carries `env: { IS_SANDBOX: "1" }` and this adapter
|
|
87
|
+
* loads user settings; that is a hole in the scrub covering a bug, not a fix.
|
|
88
|
+
*
|
|
89
|
+
* The condition here is the CLI's own, no wider: `full` AND uid 0. It is safe
|
|
90
|
+
* precisely where it is applied — `bypassPermissions` is the mode in which the
|
|
91
|
+
* SDK never calls `canUseTool` (it says so itself), so layer 1 is already inert
|
|
92
|
+
* and there is nothing left for `IS_SANDBOX` to weaken. In every other mode the
|
|
93
|
+
* scrub stands, which is what the comment on `ENV_ALLOWLIST` has always meant.
|
|
94
|
+
*/
|
|
95
|
+
function agentEnv(mode) {
|
|
96
|
+
const env = scrubbedEnv();
|
|
97
|
+
if (mode === 'full' && process.getuid?.() === 0)
|
|
98
|
+
env['IS_SANDBOX'] = '1';
|
|
99
|
+
return env;
|
|
100
|
+
}
|
|
68
101
|
// Normalized mode → Claude permission mode (session-5 plan §2). `full` is the
|
|
69
102
|
// owner's explicit call (2026-07-24): "same as Claude works now, we don't
|
|
70
103
|
// restrict anything" — in that mode the SDK stops calling canUseTool at all,
|
|
71
104
|
// so the layer-1 policy cannot gate tools either; the dashboard says so.
|
|
105
|
+
//
|
|
106
|
+
// `auto` maps to the CLI's `default` rung, and the reason is the whole of
|
|
107
|
+
// QA-128's one surviving BLOCKER — it was `'auto'` for most of this ticket and
|
|
108
|
+
// had to come back.
|
|
109
|
+
//
|
|
110
|
+
// The CLI's own `auto` rung runs a MODEL CLASSIFIER over each tool call, and
|
|
111
|
+
// what the classifier approves never becomes a `can_use_tool` request. This
|
|
112
|
+
// adapter's only route into layer 1 is `canUseTool`. So in that rung
|
|
113
|
+
// `evaluateToolUse` is not consulted, and the rules the product calls
|
|
114
|
+
// unconditional — `sudo`, `git push`, docker control, secret paths, writes
|
|
115
|
+
// outside the worktree, anything inside `.git` — are simply not applied.
|
|
116
|
+
//
|
|
117
|
+
// Measured, not reasoned. Same adapter, same options, only the model differs:
|
|
118
|
+
//
|
|
119
|
+
// sonnet (has the auto rung) : `sudo -n id` → ran, policy consulted 0 times
|
|
120
|
+
// haiku-4.5 (has no auto rung) : `sudo -n id` → DENIED by policy
|
|
121
|
+
//
|
|
122
|
+
// Haiku is why this took a second pass to see: it refuses the rung outright
|
|
123
|
+
// (`auto mode unavailable for this model`) and the CLI quietly falls back to
|
|
124
|
+
// `default`, so a probe run on it shows the layer working perfectly.
|
|
125
|
+
//
|
|
126
|
+
// Nothing is lost by mapping to `default`. The CLI's rung was never what made
|
|
127
|
+
// «Auto» mean «only the hard limits» — `effectiveTrust(trust, 'auto') → AUTO`
|
|
128
|
+
// in `policy.ts` is, and it needs `canUseTool` to be reached to say so. What
|
|
129
|
+
// `default` gives up is the classifier's convenience; what it keeps is the only
|
|
130
|
+
// place the hard limits are enforced at all.
|
|
131
|
+
//
|
|
132
|
+
// `full` is the deliberate exception: `bypassPermissions` also stops calling
|
|
133
|
+
// `canUseTool`, and there that IS the meaning of the mode — the dashboard says
|
|
134
|
+
// so, and STRICT workspaces are not offered it.
|
|
72
135
|
const MODE_TO_PERMISSION = {
|
|
73
136
|
ask: 'default',
|
|
74
137
|
plan: 'plan',
|
|
75
|
-
auto: '
|
|
138
|
+
auto: 'default',
|
|
76
139
|
full: 'bypassPermissions',
|
|
77
140
|
};
|
|
78
141
|
const SYSTEM_APPEND = [
|
|
@@ -186,6 +249,23 @@ class ClaudeSession {
|
|
|
186
249
|
/** Guards against overlapping capability probes. */
|
|
187
250
|
capabilitiesInFlight = false;
|
|
188
251
|
mode;
|
|
252
|
+
/**
|
|
253
|
+
* A launch-time mode this workspace does not allow, remembered so the feed
|
|
254
|
+
* can say so once the event queue is live (`full` on a STRICT workspace).
|
|
255
|
+
*/
|
|
256
|
+
modeRefusedAtLaunch = null;
|
|
257
|
+
/**
|
|
258
|
+
* This process was launched with the bypass capability, so `setPermissionMode`
|
|
259
|
+
* may reach `bypassPermissions` on it.
|
|
260
|
+
*
|
|
261
|
+
* The CLI ties the capability to the LAUNCH, not to the current mode: a
|
|
262
|
+
* session started with the flag can be tightened to `default` and raised back
|
|
263
|
+
* to bypass freely (both verified live), while one started without it is
|
|
264
|
+
* refused outright. So this is the honest predicate for
|
|
265
|
+
* `modeSwitchNeedsRelaunch`, and it is not the same question as
|
|
266
|
+
* «is the mode `full` right now».
|
|
267
|
+
*/
|
|
268
|
+
launchedWithBypass;
|
|
189
269
|
model;
|
|
190
270
|
/**
|
|
191
271
|
* Reasoning effort pinned for this session (ticket #111).
|
|
@@ -244,10 +324,56 @@ class ClaudeSession {
|
|
|
244
324
|
* about the task — the live set typically precedes it (QA-111 m4).
|
|
245
325
|
*/
|
|
246
326
|
skipTaskIds = new Set();
|
|
327
|
+
/**
|
|
328
|
+
* Uuid of the last message the CLI put in its transcript (ticket #126).
|
|
329
|
+
*
|
|
330
|
+
* This is the anchor a conversation rewind is measured from: handing it back
|
|
331
|
+
* as `resumeSessionAt` resumes the session with everything after it dropped.
|
|
332
|
+
* Every message type carries one — user echoes included, which is what makes
|
|
333
|
+
* "rewind to just before MY message" expressible at all.
|
|
334
|
+
*/
|
|
335
|
+
lastMessageUuid = null;
|
|
336
|
+
/**
|
|
337
|
+
* A Stop the USER asked for is in flight.
|
|
338
|
+
*
|
|
339
|
+
* Aborting a turn mid-tool-use makes the SDK end it with
|
|
340
|
+
* `result{subtype:'error_during_execution'}`, which is indistinguishable
|
|
341
|
+
* from a crash by its shape alone — and `turn_end{ok:false}` puts the whole
|
|
342
|
+
* session in FAILED. So the one thing that CAN tell them apart is whether we
|
|
343
|
+
* asked: this flag is set by `interrupt()` and read by the result that
|
|
344
|
+
* follows it.
|
|
345
|
+
*
|
|
346
|
+
* Codex has always drawn the same distinction (`status: 'interrupted'` is
|
|
347
|
+
* reported `ok: true`); this is Claude catching up, not a new rule.
|
|
348
|
+
*/
|
|
349
|
+
aborting = false;
|
|
350
|
+
/**
|
|
351
|
+
* The conversation is moving again — whatever a Stop armed is spent.
|
|
352
|
+
*
|
|
353
|
+
* Called from EVERY path that hands the agent something to act on, not just
|
|
354
|
+
* from `send()`: a Stop with nothing to interrupt produces no result at all,
|
|
355
|
+
* so a flag cleared only on a result stays armed and reports the NEXT turn's
|
|
356
|
+
* genuine failure as a clean stop (QA-120 M2). That is the original defect
|
|
357
|
+
* with the sign flipped, and the sign that loses data is this one — a run
|
|
358
|
+
* that failed would be reported as finished, with the reason dropped.
|
|
359
|
+
*/
|
|
360
|
+
resumingTurn() {
|
|
361
|
+
this.aborting = false;
|
|
362
|
+
}
|
|
247
363
|
events = this.output;
|
|
248
364
|
constructor(spec, queryFn) {
|
|
249
365
|
this.spec = spec;
|
|
250
|
-
|
|
366
|
+
// Refused rather than honoured, and BEFORE anything is built from it: on a
|
|
367
|
+
// STRICT workspace `full` would launch the CLI in `bypassPermissions`, and
|
|
368
|
+
// there layer 1 is never consulted — the manager's shield would be spent by
|
|
369
|
+
// whoever opened the session (ticket #156). The notice goes out in the
|
|
370
|
+
// constructor's own emit block below, once `output` can carry it.
|
|
371
|
+
const requested = spec.mode ?? 'ask';
|
|
372
|
+
this.mode = availableModes(spec.trustMode).includes(requested) ? requested : 'ask';
|
|
373
|
+
if (this.mode !== requested) {
|
|
374
|
+
this.modeRefusedAtLaunch = requested;
|
|
375
|
+
}
|
|
376
|
+
this.launchedWithBypass = this.mode === 'full';
|
|
251
377
|
if (spec.model)
|
|
252
378
|
this.model = spec.model;
|
|
253
379
|
// Gated, not copied: a pin stored against a model that no longer offers it
|
|
@@ -277,7 +403,7 @@ class ClaudeSession {
|
|
|
277
403
|
this.mcpConfigFile = mcpConfigFile;
|
|
278
404
|
const options = {
|
|
279
405
|
cwd: spec.cwd,
|
|
280
|
-
env:
|
|
406
|
+
env: agentEnv(this.mode),
|
|
281
407
|
/**
|
|
282
408
|
* Everything the machine's own Claude has (owner's call, 2026-07-30).
|
|
283
409
|
*
|
|
@@ -299,6 +425,14 @@ class ClaudeSession {
|
|
|
299
425
|
*/
|
|
300
426
|
settingSources: ['user', 'project', 'local'],
|
|
301
427
|
permissionMode: MODE_TO_PERMISSION[this.mode],
|
|
428
|
+
// The SDK's own words: "Must be set to `true` when using
|
|
429
|
+
// `permissionMode: 'bypassPermissions'`." It was never set, so
|
|
430
|
+
// «Unrestricted» asked the CLI for a mode it had not been given
|
|
431
|
+
// permission to offer (ticket #156). Passed only for `full`: as root the
|
|
432
|
+
// CLI refuses to START at all when this flag is present without
|
|
433
|
+
// `IS_SANDBOX=1`, so setting it unconditionally would take every mode
|
|
434
|
+
// down with it — verified live, exit code 1 on a plain `ask` session.
|
|
435
|
+
...(this.mode === 'full' ? { allowDangerouslySkipPermissions: true } : {}),
|
|
302
436
|
systemPrompt: {
|
|
303
437
|
type: 'preset',
|
|
304
438
|
preset: 'claude_code',
|
|
@@ -321,6 +455,15 @@ class ClaudeSession {
|
|
|
321
455
|
// `applyFlagSettings` call `setEffort` makes.
|
|
322
456
|
...(spec.effort === ULTRACODE ? { settings: { ultracode: true } } : {}),
|
|
323
457
|
...(spec.resumeProviderSessionId ? { resume: spec.resumeProviderSessionId } : {}),
|
|
458
|
+
// Ticket #126, conversation rewind. `resumeSessionAt` replays the
|
|
459
|
+
// transcript only up to (and including) this uuid; `forkSession` makes
|
|
460
|
+
// the result a NEW session id instead of truncating the old file, so the
|
|
461
|
+
// conversation the user rewound away from is still on disk afterwards.
|
|
462
|
+
// The SDK's own note applies and is repeated in the UI: a forked session
|
|
463
|
+
// starts without undo history.
|
|
464
|
+
...(spec.resumeProviderSessionId && spec.resumeAtAnchor
|
|
465
|
+
? { resumeSessionAt: spec.resumeAtAnchor, forkSession: true }
|
|
466
|
+
: {}),
|
|
324
467
|
...(spec.maxBudgetUsd !== undefined ? { maxBudgetUsd: spec.maxBudgetUsd } : {}),
|
|
325
468
|
// Ticket #119. NOT `mcpServers` — the SDK JSON-stringifies that option
|
|
326
469
|
// straight into argv (`sdk.mjs`: `H.push("--mcp-config", Re({mcpServers:ke}))`),
|
|
@@ -357,6 +500,10 @@ class ClaudeSession {
|
|
|
357
500
|
if (this.mcpFallbackNotice) {
|
|
358
501
|
this.emit({ type: 'notice', level: 'warn', text: this.mcpFallbackNotice });
|
|
359
502
|
}
|
|
503
|
+
if (this.modeRefusedAtLaunch) {
|
|
504
|
+
this.emit({ type: 'notice', level: 'warn', text: MODE_REFUSED_TEXT });
|
|
505
|
+
this.emit({ type: 'settings', mode: this.mode });
|
|
506
|
+
}
|
|
360
507
|
this.lastTaskFingerprint = JSON.stringify({ done: 0, total: 0, tasks: [] });
|
|
361
508
|
this.emit({ type: 'agent_tasks', tasks: [], done: 0, total: 0 });
|
|
362
509
|
// Report what the agent can do right away. `system:init` only arrives with
|
|
@@ -560,7 +707,11 @@ class ClaudeSession {
|
|
|
560
707
|
this.adoptLiveModel(this.liveWireModel ?? this.model);
|
|
561
708
|
const capabilities = {
|
|
562
709
|
models: this.knownModels,
|
|
563
|
-
|
|
710
|
+
// Read from `spec` on every publication, not captured once: the workspace
|
|
711
|
+
// trust level changes under a running session (`setWorkspacePolicy`), and
|
|
712
|
+
// a manager tightening to STRICT has to see «Unrestricted» leave the
|
|
713
|
+
// picker rather than stay there as an offer that will be refused.
|
|
714
|
+
modes: availableModes(this.spec.trustMode),
|
|
564
715
|
// Cap the list: it lands in an event payload with a hard size limit.
|
|
565
716
|
commands: commands.slice(0, 150).map((c) => ({
|
|
566
717
|
name: c.name,
|
|
@@ -638,10 +789,103 @@ class ClaudeSession {
|
|
|
638
789
|
// worst case. Fire-and-forget, same as the end-of-turn call.
|
|
639
790
|
this.refreshContextUsage();
|
|
640
791
|
}
|
|
792
|
+
/**
|
|
793
|
+
* Can this live process reach `mode`, or does the supervisor have to bring a
|
|
794
|
+
* new one up? (ticket #156)
|
|
795
|
+
*
|
|
796
|
+
* Only the `full` boundary matters, and only in the direction the CLI
|
|
797
|
+
* refuses. Crossing it OUTWARD works live — a bypass-launched session accepts
|
|
798
|
+
* `setPermissionMode('default')` and starts consulting `canUseTool` again,
|
|
799
|
+
* verified live — but it is answered `true` here as well, deliberately: that
|
|
800
|
+
* process still carries `--allow-dangerously-skip-permissions` and, as root,
|
|
801
|
+
* `IS_SANDBOX=1`. Leaving a session that is once more gated running with the
|
|
802
|
+
* ungated process's environment is the kind of residue that is correct today
|
|
803
|
+
* and quietly wrong after the next CLI release. One rule, both directions,
|
|
804
|
+
* nothing left over.
|
|
805
|
+
*/
|
|
806
|
+
modeSwitchNeedsRelaunch(mode) {
|
|
807
|
+
if (!availableModes(this.spec.trustMode).includes(mode))
|
|
808
|
+
return false;
|
|
809
|
+
return (mode === 'full') !== this.launchedWithBypass;
|
|
810
|
+
}
|
|
641
811
|
async setMode(mode) {
|
|
642
|
-
|
|
812
|
+
if (!availableModes(this.spec.trustMode).includes(mode)) {
|
|
813
|
+
this.emit({ type: 'notice', level: 'warn', text: MODE_REFUSED_TEXT });
|
|
814
|
+
// The picker has already moved; say what the mode actually is so it moves
|
|
815
|
+
// back, instead of showing a setting the session is not in.
|
|
816
|
+
this.emit({ type: 'settings', mode: this.mode });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
// The mode is recorded whatever the CLI says (QA-128). Its permission rung
|
|
820
|
+
// is an optimisation; the layer that produces the cards a user sees is
|
|
821
|
+
// ours, and it reads `this.mode`. Leaving the field behind because the CLI
|
|
822
|
+
// does not offer `auto` on this model would be #157 all over again.
|
|
823
|
+
await this.applyPermissionMode(mode);
|
|
643
824
|
this.mode = mode;
|
|
644
825
|
this.emit({ type: 'settings', mode });
|
|
826
|
+
// Ticket #157. The cards already on screen are the reason a person reaches
|
|
827
|
+
// for this control in the first place — the agent has stopped and is
|
|
828
|
+
// waiting on one. Judging only the NEXT tool call left the visible one
|
|
829
|
+
// exactly where it was, which reads as «the switch did nothing».
|
|
830
|
+
this.releasePermissionsAllowedNow();
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Move the live CLI to this mode's permission rung, degrading rather than
|
|
834
|
+
* throwing when it refuses one (QA-128).
|
|
835
|
+
*
|
|
836
|
+
* Never rethrows: the caller has already decided what the session's mode is,
|
|
837
|
+
* and the CLI's opinion about its own rung must not undo that.
|
|
838
|
+
*/
|
|
839
|
+
async applyPermissionMode(mode) {
|
|
840
|
+
try {
|
|
841
|
+
await this.q.setPermissionMode(MODE_TO_PERMISSION[mode]);
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
// Swallowed on purpose. The CLI refuses rungs per MODEL, not only per
|
|
845
|
+
// request — `Cannot set permission mode to auto: auto mode unavailable
|
|
846
|
+
// for this model`, measured on `claude-haiku-4-5`, which our own picker
|
|
847
|
+
// offers. Letting that throw is what the caller must never allow: the
|
|
848
|
+
// exception would land before `this.mode` is written, the dashboard would
|
|
849
|
+
// show the new mode, and the layer that raises the cards would still be
|
|
850
|
+
// on the old one. That is ticket #157, reproduced by its own fix.
|
|
851
|
+
log.warn('claude: setPermissionMode refused — the session mode still stands', {
|
|
852
|
+
wanted: MODE_TO_PERMISSION[mode],
|
|
853
|
+
error: String(error),
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Re-judge every open permission card under the rules in force NOW, and let
|
|
859
|
+
* through the ones that no longer need a human (ticket #157).
|
|
860
|
+
*
|
|
861
|
+
* Only in the permissive direction: a card that becomes `deny` or stays `ask`
|
|
862
|
+
* is left alone. Denying something the user is looking at — and had every
|
|
863
|
+
* right to approve — would be taking a decision away from them, which is the
|
|
864
|
+
* mistake session 12 spent its whole length undoing.
|
|
865
|
+
*/
|
|
866
|
+
releasePermissionsAllowedNow() {
|
|
867
|
+
for (const [requestId, pending] of [...this.pending]) {
|
|
868
|
+
if (!pending.fromPolicy)
|
|
869
|
+
continue;
|
|
870
|
+
const verdict = evaluateToolUse(pending.toolName, pending.input, {
|
|
871
|
+
trustMode: this.spec.trustMode,
|
|
872
|
+
mode: this.mode,
|
|
873
|
+
...(this.spec.agentAutoCommit === undefined
|
|
874
|
+
? {}
|
|
875
|
+
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
876
|
+
worktreePath: this.spec.cwd,
|
|
877
|
+
});
|
|
878
|
+
if (verdict.decision !== 'allow')
|
|
879
|
+
continue;
|
|
880
|
+
this.emit({
|
|
881
|
+
type: 'permission_resolved',
|
|
882
|
+
requestId,
|
|
883
|
+
allow: true,
|
|
884
|
+
source: 'policy',
|
|
885
|
+
reason: `the session mode changed — ${verdict.reason}`,
|
|
886
|
+
});
|
|
887
|
+
pending.resolve({ behavior: 'allow', updatedInput: pending.input });
|
|
888
|
+
}
|
|
645
889
|
}
|
|
646
890
|
/**
|
|
647
891
|
* Session 15: the project's trust level and auto-commit switch, changed
|
|
@@ -649,10 +893,50 @@ class ClaudeSession {
|
|
|
649
893
|
* every tool call, so the next one already sees it.
|
|
650
894
|
*/
|
|
651
895
|
setWorkspacePolicy(policy) {
|
|
896
|
+
const trustChanged = policy.trustMode !== undefined && policy.trustMode !== this.spec.trustMode;
|
|
652
897
|
if (policy.trustMode !== undefined)
|
|
653
898
|
this.spec.trustMode = policy.trustMode;
|
|
654
899
|
if (policy.agentAutoCommit !== undefined)
|
|
655
900
|
this.spec.agentAutoCommit = policy.agentAutoCommit;
|
|
901
|
+
if (!trustChanged)
|
|
902
|
+
return;
|
|
903
|
+
// The manager tightened to STRICT while this session was running with
|
|
904
|
+
// nothing gated. Waiting for a relaunch would leave the shield down for the
|
|
905
|
+
// rest of the turn, so this one is applied live and immediately: the CLI
|
|
906
|
+
// accepts a TIGHTENING from bypass on the same process (verified live), and
|
|
907
|
+
// from the next tool call `canUseTool` — and layer 1 with it — is consulted
|
|
908
|
+
// again. The process keeps the launch flags it can no longer use; being
|
|
909
|
+
// gated a moment sooner is worth more than that tidiness.
|
|
910
|
+
if (this.mode === 'full' && !availableModes(this.spec.trustMode).includes('full')) {
|
|
911
|
+
this.mode = 'ask';
|
|
912
|
+
// The WITHDRAWN sentence, not the refusal one (QA-128): nobody in this
|
|
913
|
+
// session asked for anything — a manager changed the project.
|
|
914
|
+
this.emit({ type: 'notice', level: 'warn', text: MODE_WITHDRAWN_TEXT });
|
|
915
|
+
this.emit({ type: 'settings', mode: this.mode });
|
|
916
|
+
void this.q.setPermissionMode(MODE_TO_PERMISSION[this.mode]).catch((error) => {
|
|
917
|
+
// The control request is the ONLY thing standing between «the manager
|
|
918
|
+
// set this project to Strict» and an agent that still gates nothing
|
|
919
|
+
// (QA-128). A warning in journald is not a response to that: if the
|
|
920
|
+
// process will not be tightened, it does not get to keep running.
|
|
921
|
+
log.warn('claude: could not tighten a full session after a STRICT switch', {
|
|
922
|
+
error: String(error),
|
|
923
|
+
});
|
|
924
|
+
this.emit({
|
|
925
|
+
type: 'notice',
|
|
926
|
+
level: 'warn',
|
|
927
|
+
text: 'This project was set to Strict trust and the running agent refused to switch its permission checks back on, so the session was stopped. Start it again to continue.',
|
|
928
|
+
});
|
|
929
|
+
this.stop('session_stopped');
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
// A tightening to STRICT has to take `full` out of the picker, and a
|
|
933
|
+
// loosening has to put it back — the list is computed from `spec` and only
|
|
934
|
+
// reaches the dashboard through a capabilities frame (ticket #156).
|
|
935
|
+
this.refreshCapabilities();
|
|
936
|
+
// Loosening the workspace releases what it no longer needs a human for, the
|
|
937
|
+
// same way a mode switch does. Tightening changes nothing here on purpose:
|
|
938
|
+
// this only ever lets cards through, never withdraws one.
|
|
939
|
+
this.releasePermissionsAllowedNow();
|
|
656
940
|
}
|
|
657
941
|
/**
|
|
658
942
|
* Change the reasoning-effort level (tickets #111, #112).
|
|
@@ -1010,6 +1294,9 @@ class ClaudeSession {
|
|
|
1010
1294
|
}
|
|
1011
1295
|
const verdict = evaluateToolUse(toolName, input, {
|
|
1012
1296
|
trustMode: this.spec.trustMode,
|
|
1297
|
+
// Ticket #156: the missing argument. Everything else in this object was
|
|
1298
|
+
// already here; the session's own mode was not, so «Auto» decided nothing.
|
|
1299
|
+
mode: this.mode,
|
|
1013
1300
|
...(this.spec.agentAutoCommit === undefined
|
|
1014
1301
|
? {}
|
|
1015
1302
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
@@ -1037,10 +1324,13 @@ class ClaudeSession {
|
|
|
1037
1324
|
...(opts.description ? { description: opts.description } : {}),
|
|
1038
1325
|
input: truncateInput(input),
|
|
1039
1326
|
});
|
|
1040
|
-
return this.waitForAnswer(opts, toolName);
|
|
1327
|
+
return this.waitForAnswer(opts, toolName, input);
|
|
1041
1328
|
}
|
|
1042
1329
|
/** Park the tool call until the dashboard answers (or the request aborts). */
|
|
1043
|
-
waitForAnswer(opts, toolName
|
|
1330
|
+
waitForAnswer(opts, toolName,
|
|
1331
|
+
// The RAW input, not the truncated copy that went on the card: this is what
|
|
1332
|
+
// the tool is released with, and what a later re-judgement is judged on.
|
|
1333
|
+
input) {
|
|
1044
1334
|
return new Promise((resolve) => {
|
|
1045
1335
|
const settle = (result) => {
|
|
1046
1336
|
if (this.pending.delete(opts.requestId))
|
|
@@ -1048,6 +1338,8 @@ class ClaudeSession {
|
|
|
1048
1338
|
};
|
|
1049
1339
|
this.pending.set(opts.requestId, {
|
|
1050
1340
|
toolName,
|
|
1341
|
+
input: input ?? {},
|
|
1342
|
+
fromPolicy: input !== undefined,
|
|
1051
1343
|
resolve: settle,
|
|
1052
1344
|
});
|
|
1053
1345
|
opts.signal.addEventListener('abort', () => {
|
|
@@ -1110,6 +1402,7 @@ class ClaudeSession {
|
|
|
1110
1402
|
log.warn('claude: answer for a question that is no longer open', { askId: reply.askId });
|
|
1111
1403
|
return false;
|
|
1112
1404
|
}
|
|
1405
|
+
this.resumingTurn();
|
|
1113
1406
|
// Validated BEFORE the ask is consumed: an answer with nothing in it would
|
|
1114
1407
|
// otherwise release the tool call with `answers: {}` — the very shape of
|
|
1115
1408
|
// the auto-answer this session deleted — and leave the card unanswerable.
|
|
@@ -1219,6 +1512,7 @@ class ClaudeSession {
|
|
|
1219
1512
|
log.warn('claude: permission answer for unknown request', { requestId });
|
|
1220
1513
|
return;
|
|
1221
1514
|
}
|
|
1515
|
+
this.resumingTurn();
|
|
1222
1516
|
this.emit({
|
|
1223
1517
|
type: 'permission_resolved',
|
|
1224
1518
|
requestId,
|
|
@@ -1246,6 +1540,7 @@ class ClaudeSession {
|
|
|
1246
1540
|
this.answerQuestion({ askId: openAsk, action: 'discuss', text });
|
|
1247
1541
|
return;
|
|
1248
1542
|
}
|
|
1543
|
+
this.resumingTurn();
|
|
1249
1544
|
const accepted = this.input.push({
|
|
1250
1545
|
type: 'user',
|
|
1251
1546
|
message: { role: 'user', content: text },
|
|
@@ -1258,13 +1553,37 @@ class ClaudeSession {
|
|
|
1258
1553
|
}
|
|
1259
1554
|
}
|
|
1260
1555
|
async interrupt() {
|
|
1556
|
+
this.aborting = true;
|
|
1261
1557
|
try {
|
|
1262
1558
|
await this.q.interrupt();
|
|
1263
1559
|
}
|
|
1264
1560
|
catch (error) {
|
|
1561
|
+
// The abort never reached the SDK, so any failure that arrives now is the
|
|
1562
|
+
// agent's own and must be reported as one.
|
|
1563
|
+
this.aborting = false;
|
|
1265
1564
|
log.warn('claude: interrupt failed', { error: String(error) });
|
|
1266
1565
|
}
|
|
1267
1566
|
}
|
|
1567
|
+
conversationAnchor() {
|
|
1568
|
+
return this.lastMessageUuid;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* `/compact` — the CLI's own command, delivered as ordinary user input.
|
|
1572
|
+
*
|
|
1573
|
+
* Deliberately not routed through `send()`: that one diverts text into an
|
|
1574
|
+
* open question card, and a question is exactly the state in which the user
|
|
1575
|
+
* is most likely to look at a full context meter and press the button.
|
|
1576
|
+
*/
|
|
1577
|
+
async compact() {
|
|
1578
|
+
if (this.stopped)
|
|
1579
|
+
return false;
|
|
1580
|
+
this.resumingTurn();
|
|
1581
|
+
return this.input.push({
|
|
1582
|
+
type: 'user',
|
|
1583
|
+
message: { role: 'user', content: '/compact' },
|
|
1584
|
+
parent_tool_use_id: null,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1268
1587
|
stop(reason = 'session_stopped') {
|
|
1269
1588
|
if (this.stopped)
|
|
1270
1589
|
return;
|
|
@@ -1305,6 +1624,26 @@ class ClaudeSession {
|
|
|
1305
1624
|
async consume() {
|
|
1306
1625
|
try {
|
|
1307
1626
|
for await (const msg of this.q) {
|
|
1627
|
+
// Ticket #126: where we are in the transcript, remembered BEFORE the
|
|
1628
|
+
// message is interpreted.
|
|
1629
|
+
//
|
|
1630
|
+
// ONLY a finished assistant message of the MAIN conversation. The SDK
|
|
1631
|
+
// stamps a uuid on roughly thirty message kinds — `system:init`, hook
|
|
1632
|
+
// notifications, rate-limit events, tool progress, the closing
|
|
1633
|
+
// `result` — and none of them is something the CLI can resume at. Its
|
|
1634
|
+
// own contract is explicit: «The message ID should be from
|
|
1635
|
+
// `SDKAssistantMessage.uuid`».
|
|
1636
|
+
//
|
|
1637
|
+
// Taking whichever came last handed the CLI, on a fresh session, the
|
|
1638
|
+
// uuid of `system:init` — and the next launch died with «No message
|
|
1639
|
+
// found with message.uuid of …», which is a FAILED session and a
|
|
1640
|
+
// conversation that was never actually rewound.
|
|
1641
|
+
//
|
|
1642
|
+
// `parent_tool_use_id` excludes a subagent's own messages: they live
|
|
1643
|
+
// inside a Task tool call, not in the conversation being resumed.
|
|
1644
|
+
if (msg.type === 'assistant' && msg.parent_tool_use_id === null && msg.uuid) {
|
|
1645
|
+
this.lastMessageUuid = msg.uuid;
|
|
1646
|
+
}
|
|
1308
1647
|
switch (msg.type) {
|
|
1309
1648
|
case 'system': {
|
|
1310
1649
|
if (msg.subtype === 'init') {
|
|
@@ -1393,16 +1732,32 @@ class ClaudeSession {
|
|
|
1393
1732
|
// otherwise, and it is the last thing anybody sees.
|
|
1394
1733
|
this.endTaskTurn();
|
|
1395
1734
|
this.refreshContextUsage();
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1735
|
+
// Consumed here whichever way the turn ended: one Stop arms this
|
|
1736
|
+
// once, for exactly one result.
|
|
1737
|
+
const aborted = this.aborting;
|
|
1738
|
+
this.aborting = false;
|
|
1739
|
+
const failure = msg.subtype === 'success' ? '' : classifyError(msg.subtype, msg.errors);
|
|
1740
|
+
if (failure && isRewindError(failure)) {
|
|
1741
|
+
// Not a failed turn — a refused resume. The CLI answers a bad
|
|
1742
|
+
// `resumeSessionAt` with exactly this and nothing else (no
|
|
1743
|
+
// `system:init`, no assistant message), so reporting it as a
|
|
1744
|
+
// turn failure moved the session to FAILED for a rewind that had
|
|
1745
|
+
// simply not been possible.
|
|
1400
1746
|
this.emit({
|
|
1401
|
-
type: '
|
|
1402
|
-
|
|
1403
|
-
|
|
1747
|
+
type: 'error',
|
|
1748
|
+
message: classifyRunError(failure),
|
|
1749
|
+
code: 'rewind_failed',
|
|
1404
1750
|
});
|
|
1405
1751
|
}
|
|
1752
|
+
else if (msg.subtype === 'success' || aborted) {
|
|
1753
|
+
// A turn the user stopped is not a failed turn. Reporting it as
|
|
1754
|
+
// one moved the session to FAILED, which is terminal — pressing
|
|
1755
|
+
// Stop cost people the session they meant to keep.
|
|
1756
|
+
this.emit({ type: 'turn_end', ok: true, ...(aborted ? { aborted: true } : {}) });
|
|
1757
|
+
}
|
|
1758
|
+
else {
|
|
1759
|
+
this.emit({ type: 'turn_end', ok: false, errorMessage: failure });
|
|
1760
|
+
}
|
|
1406
1761
|
break;
|
|
1407
1762
|
}
|
|
1408
1763
|
default:
|
|
@@ -1526,7 +1881,11 @@ function stringifyContent(content) {
|
|
|
1526
1881
|
return content === undefined ? '' : JSON.stringify(content);
|
|
1527
1882
|
}
|
|
1528
1883
|
function classifyError(subtype, errors) {
|
|
1529
|
-
|
|
1884
|
+
// Optional on purpose despite the SDK's type: a `result` without `errors`
|
|
1885
|
+
// threw from inside the event pump, which the loop's catch turned into a
|
|
1886
|
+
// plain `error` event — so the turn that failed never ENDED, and the session
|
|
1887
|
+
// sat at RUNNING with no way to tell.
|
|
1888
|
+
const detail = errors?.length ? `: ${maskString(errors.join('; ').slice(0, 500))}` : '';
|
|
1530
1889
|
switch (subtype) {
|
|
1531
1890
|
case 'error_max_budget_usd':
|
|
1532
1891
|
return 'Session budget (USD) exceeded — the run was stopped';
|
|
@@ -1556,14 +1915,30 @@ function isAuthError(message) {
|
|
|
1556
1915
|
function isResumeError(message) {
|
|
1557
1916
|
return /no conversation found|session .{0,40}not found|could not resume|invalid session id/i.test(message);
|
|
1558
1917
|
}
|
|
1918
|
+
/**
|
|
1919
|
+
* The CLI refused the point we asked it to resume at.
|
|
1920
|
+
*
|
|
1921
|
+
* Its exact wording, captured from the CLI itself: «No message found with
|
|
1922
|
+
* message.uuid of: <uuid>». It arrives as a `result` with no `system:init`
|
|
1923
|
+
* before it, and the query then throws with the same sentence — so this is
|
|
1924
|
+
* matched on both paths.
|
|
1925
|
+
*/
|
|
1926
|
+
function isRewindError(message) {
|
|
1927
|
+
return /no message found with message\.uuid/i.test(message);
|
|
1928
|
+
}
|
|
1559
1929
|
function errorCode(message) {
|
|
1560
1930
|
if (isAuthError(message))
|
|
1561
1931
|
return 'auth_expired';
|
|
1932
|
+
if (isRewindError(message))
|
|
1933
|
+
return 'rewind_failed';
|
|
1562
1934
|
if (isResumeError(message))
|
|
1563
1935
|
return 'resume_failed';
|
|
1564
1936
|
return undefined;
|
|
1565
1937
|
}
|
|
1566
1938
|
export function classifyRunError(message) {
|
|
1939
|
+
if (isRewindError(message)) {
|
|
1940
|
+
return 'The conversation could not be rewound to that point — the agent still remembers everything after it';
|
|
1941
|
+
}
|
|
1567
1942
|
if (isAuthError(message)) {
|
|
1568
1943
|
return 'Claude authentication expired on this server — re-login is required (claude setup-token)';
|
|
1569
1944
|
}
|