@bridge4dev/runner 0.29.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.
@@ -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 { AGENT_MODES, } from './types.js';
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: 'acceptEdits',
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).
@@ -283,7 +363,17 @@ class ClaudeSession {
283
363
  events = this.output;
284
364
  constructor(spec, queryFn) {
285
365
  this.spec = spec;
286
- this.mode = spec.mode ?? 'ask';
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';
287
377
  if (spec.model)
288
378
  this.model = spec.model;
289
379
  // Gated, not copied: a pin stored against a model that no longer offers it
@@ -313,7 +403,7 @@ class ClaudeSession {
313
403
  this.mcpConfigFile = mcpConfigFile;
314
404
  const options = {
315
405
  cwd: spec.cwd,
316
- env: scrubbedEnv(),
406
+ env: agentEnv(this.mode),
317
407
  /**
318
408
  * Everything the machine's own Claude has (owner's call, 2026-07-30).
319
409
  *
@@ -335,6 +425,14 @@ class ClaudeSession {
335
425
  */
336
426
  settingSources: ['user', 'project', 'local'],
337
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 } : {}),
338
436
  systemPrompt: {
339
437
  type: 'preset',
340
438
  preset: 'claude_code',
@@ -402,6 +500,10 @@ class ClaudeSession {
402
500
  if (this.mcpFallbackNotice) {
403
501
  this.emit({ type: 'notice', level: 'warn', text: this.mcpFallbackNotice });
404
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
+ }
405
507
  this.lastTaskFingerprint = JSON.stringify({ done: 0, total: 0, tasks: [] });
406
508
  this.emit({ type: 'agent_tasks', tasks: [], done: 0, total: 0 });
407
509
  // Report what the agent can do right away. `system:init` only arrives with
@@ -605,7 +707,11 @@ class ClaudeSession {
605
707
  this.adoptLiveModel(this.liveWireModel ?? this.model);
606
708
  const capabilities = {
607
709
  models: this.knownModels,
608
- modes: [...AGENT_MODES],
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),
609
715
  // Cap the list: it lands in an event payload with a hard size limit.
610
716
  commands: commands.slice(0, 150).map((c) => ({
611
717
  name: c.name,
@@ -683,10 +789,103 @@ class ClaudeSession {
683
789
  // worst case. Fire-and-forget, same as the end-of-turn call.
684
790
  this.refreshContextUsage();
685
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
+ }
686
811
  async setMode(mode) {
687
- await this.q.setPermissionMode(MODE_TO_PERMISSION[mode]);
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);
688
824
  this.mode = mode;
689
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
+ }
690
889
  }
691
890
  /**
692
891
  * Session 15: the project's trust level and auto-commit switch, changed
@@ -694,10 +893,50 @@ class ClaudeSession {
694
893
  * every tool call, so the next one already sees it.
695
894
  */
696
895
  setWorkspacePolicy(policy) {
896
+ const trustChanged = policy.trustMode !== undefined && policy.trustMode !== this.spec.trustMode;
697
897
  if (policy.trustMode !== undefined)
698
898
  this.spec.trustMode = policy.trustMode;
699
899
  if (policy.agentAutoCommit !== undefined)
700
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();
701
940
  }
702
941
  /**
703
942
  * Change the reasoning-effort level (tickets #111, #112).
@@ -1055,6 +1294,9 @@ class ClaudeSession {
1055
1294
  }
1056
1295
  const verdict = evaluateToolUse(toolName, input, {
1057
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,
1058
1300
  ...(this.spec.agentAutoCommit === undefined
1059
1301
  ? {}
1060
1302
  : { agentAutoCommit: this.spec.agentAutoCommit }),
@@ -1082,10 +1324,13 @@ class ClaudeSession {
1082
1324
  ...(opts.description ? { description: opts.description } : {}),
1083
1325
  input: truncateInput(input),
1084
1326
  });
1085
- return this.waitForAnswer(opts, toolName);
1327
+ return this.waitForAnswer(opts, toolName, input);
1086
1328
  }
1087
1329
  /** Park the tool call until the dashboard answers (or the request aborts). */
1088
- 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) {
1089
1334
  return new Promise((resolve) => {
1090
1335
  const settle = (result) => {
1091
1336
  if (this.pending.delete(opts.requestId))
@@ -1093,6 +1338,8 @@ class ClaudeSession {
1093
1338
  };
1094
1339
  this.pending.set(opts.requestId, {
1095
1340
  toolName,
1341
+ input: input ?? {},
1342
+ fromPolicy: input !== undefined,
1096
1343
  resolve: settle,
1097
1344
  });
1098
1345
  opts.signal.addEventListener('abort', () => {
@@ -5,7 +5,7 @@ import { RUNNER_VERSION } from '../version.js';
5
5
  import { repairCodexAuth } from './codex-home.js';
6
6
  import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
7
7
  import { truncate } from './claude.js';
8
- import { AGENT_MODES, } from './types.js';
8
+ import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
9
9
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
10
10
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
11
11
  // contract is unchanged, so the dashboard renders Codex sessions with the same
@@ -153,6 +153,8 @@ class CodexSession {
153
153
  heldPlan = null;
154
154
  lastCollabMode = null;
155
155
  mode;
156
+ /** A launch-time `full` this STRICT workspace does not allow (ticket #156). */
157
+ modeRefusedAtLaunch = false;
156
158
  model;
157
159
  effort;
158
160
  /** Last model catalogue from `model/list` — effort sets differ per model. */
@@ -164,7 +166,13 @@ class CodexSession {
164
166
  constructor(spec, home, deps) {
165
167
  this.spec = spec;
166
168
  this.home = home;
167
- this.mode = spec.mode ?? 'ask';
169
+ // `full` on a STRICT workspace is refused, not honoured: Codex in that mode
170
+ // sends no approvals at all, so layer 1 never runs and the manager's shield
171
+ // would be spent from the chat (ticket #156).
172
+ const requestedMode = spec.mode ?? 'ask';
173
+ this.mode = availableModes(spec.trustMode).includes(requestedMode) ? requestedMode : 'ask';
174
+ if (this.mode !== requestedMode)
175
+ this.modeRefusedAtLaunch = true;
168
176
  this.model = spec.model;
169
177
  this.effort = spec.effort;
170
178
  this.repairHome = deps.repairHome ?? (deps.codexHome ? null : repairCodexAuth);
@@ -183,6 +191,10 @@ class CodexSession {
183
191
  cwd: spec.cwd,
184
192
  ...wiring,
185
193
  });
194
+ if (this.modeRefusedAtLaunch) {
195
+ this.notice('warn', MODE_REFUSED_TEXT);
196
+ this.emit({ type: 'settings', mode: this.mode });
197
+ }
186
198
  void this.boot();
187
199
  }
188
200
  // ─── Boot ──────────────────────────────────────────────────────────
@@ -590,10 +602,28 @@ class CodexSession {
590
602
  * tool call, so the next one already sees it.
591
603
  */
592
604
  setWorkspacePolicy(policy) {
605
+ const trustChanged = policy.trustMode !== undefined && policy.trustMode !== this.spec.trustMode;
593
606
  if (policy.trustMode !== undefined)
594
607
  this.spec.trustMode = policy.trustMode;
595
608
  if (policy.agentAutoCommit !== undefined)
596
609
  this.spec.agentAutoCommit = policy.agentAutoCommit;
610
+ if (!trustChanged)
611
+ return;
612
+ // A manager tightened the project out from under a session that is running
613
+ // with nothing gated (QA-128). Codex carries `approvalPolicy` on the next
614
+ // `turn/start`, so recording the mode here IS the fix — the next turn is
615
+ // `on-request` and layer 1 sees every command again. Claude does the same
616
+ // thing through `setPermissionMode`; leaving this out of one adapter left
617
+ // the picker showing a mode that was no longer in its own list.
618
+ if (this.mode === 'full' && !availableModes(this.spec.trustMode).includes('full')) {
619
+ this.mode = 'ask';
620
+ this.notice('warn', MODE_WITHDRAWN_TEXT);
621
+ this.emit({ type: 'settings', mode: this.mode });
622
+ }
623
+ // `full` leaves (or rejoins) the picker with the trust level — the list is
624
+ // computed from `spec` and only travels in a capabilities frame (#156).
625
+ this.refreshCapabilities();
626
+ this.releaseApprovalsAllowedNow();
597
627
  }
598
628
  async setEffort(effort) {
599
629
  if (effort)
@@ -603,9 +633,56 @@ class CodexSession {
603
633
  this.emit({ type: 'settings', effort });
604
634
  this.refreshCapabilities();
605
635
  }
636
+ /**
637
+ * Codex needs no new process for any mode: `approvalPolicy` and the sandbox
638
+ * policy travel with the next `turn/start`, so the change is in force from
639
+ * the next turn whatever it is (ticket #156).
640
+ */
641
+ modeSwitchNeedsRelaunch(_mode) {
642
+ return false;
643
+ }
606
644
  async setMode(mode) {
645
+ if (!availableModes(this.spec.trustMode).includes(mode)) {
646
+ this.notice('warn', MODE_REFUSED_TEXT);
647
+ this.emit({ type: 'settings', mode: this.mode });
648
+ return;
649
+ }
607
650
  this.mode = mode;
608
651
  this.emit({ type: 'settings', mode });
652
+ // Ticket #157 — the card the user is looking at is the reason they reached
653
+ // for the control; judging only the next one leaves it sitting there.
654
+ this.releaseApprovalsAllowedNow();
655
+ }
656
+ /**
657
+ * Re-judge every open approval under the rules in force NOW and accept the
658
+ * ones that no longer need a human (ticket #157). Permissive direction only:
659
+ * a card that would now be denied is left for the user, because taking a
660
+ * decision away from them is the mistake session 12 exists to undo.
661
+ */
662
+ releaseApprovalsAllowedNow() {
663
+ for (const [requestId, pending] of [...this.approvals]) {
664
+ if (!pending.policy)
665
+ continue;
666
+ const verdict = evaluateToolUse(pending.policy.tool, pending.policy.input, {
667
+ trustMode: this.spec.trustMode,
668
+ mode: this.mode,
669
+ ...(this.spec.agentAutoCommit === undefined
670
+ ? {}
671
+ : { agentAutoCommit: this.spec.agentAutoCommit }),
672
+ worktreePath: this.spec.cwd,
673
+ });
674
+ if (verdict.decision !== 'allow')
675
+ continue;
676
+ this.approvals.delete(requestId);
677
+ this.client.respond(pending.rpcId, { decision: 'accept' });
678
+ this.emit({
679
+ type: 'permission_resolved',
680
+ requestId,
681
+ allow: true,
682
+ source: 'policy',
683
+ reason: `the session mode changed — ${verdict.reason}`,
684
+ });
685
+ }
609
686
  }
610
687
  async interrupt() {
611
688
  if (!this.threadId || !this.activeTurnId)
@@ -705,6 +782,9 @@ class CodexSession {
705
782
  ? { decision: 'ask', reason: 'details unavailable' }
706
783
  : evaluateToolUse(enriched.policyTool, enriched.policyInput, {
707
784
  trustMode: this.spec.trustMode,
785
+ // Ticket #156, the same missing argument as in the Claude adapter —
786
+ // both bridges call one policy, so both have to hand it the mode.
787
+ mode: this.mode,
708
788
  ...(this.spec.agentAutoCommit === undefined
709
789
  ? {}
710
790
  : { agentAutoCommit: this.spec.agentAutoCommit }),
@@ -725,7 +805,11 @@ class CodexSession {
725
805
  });
726
806
  return;
727
807
  }
728
- this.approvals.set(requestId, { rpcId: request.id, toolName: enriched.toolName });
808
+ this.approvals.set(requestId, {
809
+ rpcId: request.id,
810
+ toolName: enriched.toolName,
811
+ policy: enriched.forceAsk ? null : { tool: enriched.policyTool, input: enriched.policyInput },
812
+ });
729
813
  this.emit({
730
814
  type: 'permission',
731
815
  requestId,
@@ -1295,7 +1379,10 @@ class CodexSession {
1295
1379
  const currentEffort = this.effort ?? models.find((m) => m.id === currentModel)?.defaultEffort ?? undefined;
1296
1380
  const capabilities = {
1297
1381
  models,
1298
- modes: [...AGENT_MODES],
1382
+ // Recomputed on every publication rather than captured: the workspace's
1383
+ // trust level changes under a running session, and `full` has to leave
1384
+ // the picker with it (ticket #156).
1385
+ modes: availableModes(this.spec.trustMode),
1299
1386
  commands,
1300
1387
  currentMode: this.mode,
1301
1388
  ...(currentModel ? { currentModel } : {}),
@@ -5,14 +5,48 @@ export interface McpConfig {
5
5
  }
6
6
  /**
7
7
  * Agent-agnostic interaction mode (session-5 plan §2).
8
- * ask — ask before acting (Claude `default`, Codex `on-request`)
9
- * plan — plan first, act after approval (Claude `plan`, Codex on-request + plan)
10
- * auto — apply edits silently (Claude `acceptEdits`, Codex `on-failure`)
8
+ * ask — ask before acting (Claude `default`, Codex `on-request`)
9
+ * plan — plan first, act after approval (Claude `plan`, Codex on-request + plan)
10
+ * auto — work on its own, hard limits only (Claude `auto`, Codex on-request + workspace-write)
11
11
  * full — never ask (Claude `bypassPermissions`, Codex `never`)
12
+ *
13
+ * The mode is BOTH halves of the decision, and that took a bug to learn
14
+ * (ticket #156): it sets the agent CLI's own permission mode, AND it reaches
15
+ * `evaluateToolUse` as `PolicyContext.mode`. For four sessions it did only the
16
+ * first, so «Auto» moved a dial the layer that raises the cards never read.
12
17
  */
13
18
  export type AgentMode = 'ask' | 'plan' | 'auto' | 'full';
14
19
  export declare const AGENT_MODES: readonly AgentMode[];
15
20
  export declare function isAgentMode(value: unknown): value is AgentMode;
21
+ /**
22
+ * Said in the feed whenever `full` is asked for on a STRICT workspace — at
23
+ * launch and on a live switch. One string, because it is one refusal, and the
24
+ * user should not have to notice that they arrived at it two different ways.
25
+ */
26
+ export declare const MODE_REFUSED_TEXT = "\u00ABUnrestricted\u00BB is not available on this project. Its trust level is Strict, and \u00ABUnrestricted\u00BB is the one mode in which DevBridge checks nothing \u2014 the two cannot both be true. This session asks about every command instead. Only a project manager can change the trust level, in the server panel.";
27
+ /**
28
+ * The other half of the same rule, and a different sentence on purpose
29
+ * (QA-128): this one is shown when the mode was taken AWAY from a running
30
+ * session because a manager tightened the project, not because the user asked
31
+ * for something they may not have. Telling somebody off for a decision that was
32
+ * not theirs is how a correct refusal still reads as a bug.
33
+ */
34
+ export declare const MODE_WITHDRAWN_TEXT = "This project was just set to Strict trust, so \u00ABUnrestricted\u00BB is no longer available and this session now asks about every command. Nothing you did caused this \u2014 a project manager changed it in the server panel.";
35
+ /**
36
+ * The modes a session on THIS workspace may actually be put into (ticket #156).
37
+ *
38
+ * One entry differs from `AGENT_MODES`, and it is load-bearing: on a `STRICT`
39
+ * workspace `full` is not offered at all.
40
+ *
41
+ * `full` is the one mode in which layer 1 does not run — the agent CLI stops
42
+ * asking, so `evaluateToolUse` is never called and there is nothing left to
43
+ * enforce. On every other workspace that is the owner's explicit choice
44
+ * (2026-07-24: «full restricts nothing»). On a `STRICT` one it would be the
45
+ * opposite: a shield the manager set, spent from the chat by whoever opened the
46
+ * session. So the mode is removed from the list the dashboard draws its picker
47
+ * from, and refused if it arrives anyway.
48
+ */
49
+ export declare function availableModes(trustMode: TrustMode): AgentMode[];
16
50
  /**
17
51
  * A reasoning-effort level a model supports. Codex advertises these per model
18
52
  * (`supportedReasoningEfforts`) and the set differs between models — the new
@@ -388,6 +422,24 @@ export interface AgentSession {
388
422
  setEffort(effort: string | null): Promise<void>;
389
423
  /** Switch interaction mode mid-session. */
390
424
  setMode(mode: AgentMode): Promise<void>;
425
+ /**
426
+ * Can this session reach `mode` without a new agent process? (ticket #156)
427
+ *
428
+ * Claude answers `false` for any move across the `full` boundary, and that is
429
+ * the CLI's rule rather than ours: `bypassPermissions` may only be entered by
430
+ * a process LAUNCHED with the dangerous flag, and the control request is
431
+ * refused outright otherwise —
432
+ *
433
+ * Cannot set permission mode to bypassPermissions because the session was
434
+ * not launched with --dangerously-skip-permissions
435
+ *
436
+ * (verified live against the bundled CLI 2.1.218). Until this existed the
437
+ * supervisor caught that error, turned it into a `notice`, and left the user
438
+ * with a picker reading «Unrestricted» over a session that was still asking.
439
+ *
440
+ * Codex answers `true` always: its policy travels with the next `turn/start`.
441
+ */
442
+ modeSwitchNeedsRelaunch(mode: AgentMode): boolean;
391
443
  /**
392
444
  * Project-level policy changed while this session is running (session 15).
393
445
  *
@@ -2,4 +2,35 @@ export const AGENT_MODES = ['ask', 'plan', 'auto', 'full'];
2
2
  export function isAgentMode(value) {
3
3
  return typeof value === 'string' && AGENT_MODES.includes(value);
4
4
  }
5
+ /**
6
+ * Said in the feed whenever `full` is asked for on a STRICT workspace — at
7
+ * launch and on a live switch. One string, because it is one refusal, and the
8
+ * user should not have to notice that they arrived at it two different ways.
9
+ */
10
+ export const MODE_REFUSED_TEXT = '«Unrestricted» is not available on this project. Its trust level is Strict, and «Unrestricted» is the one mode in which DevBridge checks nothing — the two cannot both be true. This session asks about every command instead. Only a project manager can change the trust level, in the server panel.';
11
+ /**
12
+ * The other half of the same rule, and a different sentence on purpose
13
+ * (QA-128): this one is shown when the mode was taken AWAY from a running
14
+ * session because a manager tightened the project, not because the user asked
15
+ * for something they may not have. Telling somebody off for a decision that was
16
+ * not theirs is how a correct refusal still reads as a bug.
17
+ */
18
+ export const MODE_WITHDRAWN_TEXT = 'This project was just set to Strict trust, so «Unrestricted» is no longer available and this session now asks about every command. Nothing you did caused this — a project manager changed it in the server panel.';
19
+ /**
20
+ * The modes a session on THIS workspace may actually be put into (ticket #156).
21
+ *
22
+ * One entry differs from `AGENT_MODES`, and it is load-bearing: on a `STRICT`
23
+ * workspace `full` is not offered at all.
24
+ *
25
+ * `full` is the one mode in which layer 1 does not run — the agent CLI stops
26
+ * asking, so `evaluateToolUse` is never called and there is nothing left to
27
+ * enforce. On every other workspace that is the owner's explicit choice
28
+ * (2026-07-24: «full restricts nothing»). On a `STRICT` one it would be the
29
+ * opposite: a shield the manager set, spent from the chat by whoever opened the
30
+ * session. So the mode is removed from the list the dashboard draws its picker
31
+ * from, and refused if it arrives anyway.
32
+ */
33
+ export function availableModes(trustMode) {
34
+ return trustMode === 'STRICT' ? AGENT_MODES.filter((mode) => mode !== 'full') : [...AGENT_MODES];
35
+ }
5
36
  //# sourceMappingURL=types.js.map
package/dist/policy.d.ts CHANGED
@@ -1,6 +1,20 @@
1
+ import type { AgentMode } from './adapters/types.js';
1
2
  export type TrustMode = 'STRICT' | 'NORMAL' | 'AUTO';
2
3
  export interface PolicyContext {
3
4
  trustMode: TrustMode;
5
+ /**
6
+ * The interaction mode the SESSION is in — the dial in the composer, as
7
+ * opposed to `trustMode`, which is the workspace's (ticket #156).
8
+ *
9
+ * It was missing here for four sessions, and that is the whole of #156: the
10
+ * user switched to «Auto» and the agent went on asking, because the function
11
+ * that decides whether to ask had never heard of the mode. A control that
12
+ * moves a value nothing reads is worse than a missing one.
13
+ *
14
+ * `undefined` means an older API/runner pair that does not send it, and there
15
+ * the answer stays exactly what it has always been — see `effectiveTrust`.
16
+ */
17
+ mode?: AgentMode;
4
18
  /** The session worktree — the only place the agent may write. */
5
19
  worktreePath: string;
6
20
  /**
@@ -76,5 +90,31 @@ export interface RecipeCommandDecision {
76
90
  * nowhere else. Without the name, the refusal stands and says why.
77
91
  */
78
92
  export declare function evaluateRecipeCommand(command: string, ctx?: RecipeCommandContext): RecipeCommandDecision;
93
+ /**
94
+ * Fold the workspace's trust level and the session's mode into the one value
95
+ * the rules below actually consult (ticket #156).
96
+ *
97
+ * Three rules, and each is a sentence:
98
+ *
99
+ * 1. **`auto` rises to AUTO** — «only the hard limits». This is what makes the
100
+ * button mean what it says: Claude's own Auto does not stop to ask about
101
+ * `grep … | head`, and neither does this one any more. The hard denials
102
+ * below — `sudo`, `git push`, docker control, secret paths, writes outside
103
+ * the worktree, anything inside `.git` — are not part of the deal and are
104
+ * checked before this value is ever read.
105
+ * 2. **`ask`/`plan` never rise above NORMAL.** On an AUTO-trust workspace
106
+ * «Ask first» used to ask about nothing at all, which is a label that lies.
107
+ * This is the one direction that is STRICTER than before, and it is strict
108
+ * only where the old behaviour contradicted the word on the control.
109
+ * 3. **STRICT cannot be unlocked from the chat.** That is the entire reason
110
+ * STRICT exists: a manager sets it on the workspace, and no session-level
111
+ * dial may spend it. `full` is refused outright on such a workspace — see
112
+ * `availableModes` in the adapters, because in `bypassPermissions` this
113
+ * function is never called at all and a shield nobody consults is no shield.
114
+ *
115
+ * `mode === undefined` (an API or runner from before this release) returns the
116
+ * workspace trust unchanged, which is exactly the old behaviour.
117
+ */
118
+ export declare function effectiveTrust(trustMode: TrustMode, mode?: AgentMode): TrustMode;
79
119
  export declare function evaluateToolUse(toolName: string, input: Record<string, unknown>, ctx: PolicyContext): PolicyDecision;
80
120
  //# sourceMappingURL=policy.d.ts.map
package/dist/policy.js CHANGED
@@ -602,7 +602,46 @@ const AGENT_COMMIT = new RegExp(String.raw `\bgit\s+${FLAGS}commit\b`);
602
602
  // ─── Tool-use evaluation ─────────────────────────────────────────────
603
603
  const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'NotebookRead']);
604
604
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
605
+ /**
606
+ * Fold the workspace's trust level and the session's mode into the one value
607
+ * the rules below actually consult (ticket #156).
608
+ *
609
+ * Three rules, and each is a sentence:
610
+ *
611
+ * 1. **`auto` rises to AUTO** — «only the hard limits». This is what makes the
612
+ * button mean what it says: Claude's own Auto does not stop to ask about
613
+ * `grep … | head`, and neither does this one any more. The hard denials
614
+ * below — `sudo`, `git push`, docker control, secret paths, writes outside
615
+ * the worktree, anything inside `.git` — are not part of the deal and are
616
+ * checked before this value is ever read.
617
+ * 2. **`ask`/`plan` never rise above NORMAL.** On an AUTO-trust workspace
618
+ * «Ask first» used to ask about nothing at all, which is a label that lies.
619
+ * This is the one direction that is STRICTER than before, and it is strict
620
+ * only where the old behaviour contradicted the word on the control.
621
+ * 3. **STRICT cannot be unlocked from the chat.** That is the entire reason
622
+ * STRICT exists: a manager sets it on the workspace, and no session-level
623
+ * dial may spend it. `full` is refused outright on such a workspace — see
624
+ * `availableModes` in the adapters, because in `bypassPermissions` this
625
+ * function is never called at all and a shield nobody consults is no shield.
626
+ *
627
+ * `mode === undefined` (an API or runner from before this release) returns the
628
+ * workspace trust unchanged, which is exactly the old behaviour.
629
+ */
630
+ export function effectiveTrust(trustMode, mode) {
631
+ if (trustMode === 'STRICT')
632
+ return 'STRICT';
633
+ if (mode === undefined)
634
+ return trustMode;
635
+ if (mode === 'auto' || mode === 'full')
636
+ return 'AUTO';
637
+ // ask / plan — at most NORMAL, never AUTO.
638
+ return trustMode === 'AUTO' ? 'NORMAL' : trustMode;
639
+ }
605
640
  export function evaluateToolUse(toolName, input, ctx) {
641
+ // Read ONCE, here, and never `ctx.trustMode` again below: the branches that
642
+ // follow are the whole of layer 1, and a single one still reading the raw
643
+ // workspace value would be a hole in exactly the shape of #156.
644
+ const trust = effectiveTrust(ctx.trustMode, ctx.mode);
606
645
  // DevBridge MCP tools are the agent's job interface — always fine.
607
646
  if (toolName.startsWith('mcp__devbridge__')) {
608
647
  return { decision: 'allow', reason: 'devbridge mcp' };
@@ -633,9 +672,9 @@ export function evaluateToolUse(toolName, input, ctx) {
633
672
  }
634
673
  }
635
674
  }
636
- if (ctx.trustMode === 'STRICT')
675
+ if (trust === 'STRICT')
637
676
  return { decision: 'ask', reason: 'strict mode' };
638
- if (ctx.trustMode === 'AUTO')
677
+ if (trust === 'AUTO')
639
678
  return { decision: 'allow', reason: 'auto mode' };
640
679
  if (isSafeCommand(command)) {
641
680
  return { decision: 'allow', reason: 'safe command' };
@@ -667,22 +706,22 @@ export function evaluateToolUse(toolName, input, ctx) {
667
706
  if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
668
707
  return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
669
708
  }
670
- if (ctx.trustMode === 'STRICT')
709
+ if (trust === 'STRICT')
671
710
  return { decision: 'ask', reason: 'strict mode' };
672
711
  if (READ_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
673
- return ctx.trustMode === 'AUTO'
712
+ return trust === 'AUTO'
674
713
  ? { decision: 'allow', reason: 'auto mode' }
675
714
  : { decision: 'ask', reason: 'read outside the worktree' };
676
715
  }
677
716
  return { decision: 'allow', reason: 'inside worktree' };
678
717
  }
679
718
  if (toolName === 'WebFetch' || toolName === 'WebSearch') {
680
- if (ctx.trustMode === 'STRICT')
719
+ if (trust === 'STRICT')
681
720
  return { decision: 'ask', reason: 'strict mode' };
682
721
  return { decision: 'allow', reason: 'network read' };
683
722
  }
684
723
  // Unknown tools: ask unless the workspace is fully trusted.
685
- if (ctx.trustMode === 'AUTO')
724
+ if (trust === 'AUTO')
686
725
  return { decision: 'allow', reason: 'auto mode' };
687
726
  return { decision: 'ask', reason: `unrecognized tool ${toolName}` };
688
727
  }
@@ -317,6 +317,10 @@ export declare class Supervisor {
317
317
  private interruptSession;
318
318
  /** Live model / interaction-mode switch (persisted for the next relaunch). */
319
319
  private applySettings;
320
+ /** Is a turn (or a question the agent is parked on) in flight right now? */
321
+ private isMidTurn;
322
+ /** The three live setters, in the order that lets an explicit pick win. */
323
+ private applyLiveSettings;
320
324
  private stopSession;
321
325
  private reconcile;
322
326
  /**
@@ -17,6 +17,7 @@ import { selfUpdate } from './self-update.js';
17
17
  import { rememberWorkspacePath } from './environment.js';
18
18
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
19
19
  import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
20
+ import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
20
21
  /** Refusals shared by every checkpoint command (ticket #126). */
21
22
  const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
22
23
  const AGENT_BUSY = 'The agent is still working — stop the turn first';
@@ -626,6 +627,54 @@ export class Supervisor {
626
627
  this.launchAgent(running, prompt, running.descriptor.providerSessionId);
627
628
  return;
628
629
  }
630
+ // The mode crossed the `full` boundary: a NEW process, resuming the same
631
+ // conversation (ticket #156). Placed with the other one-shot relaunches and
632
+ // after the cleanup above, which is the whole point of routing it here.
633
+ if (running.modeRelaunch && !running.stopRequested) {
634
+ const { priorStatus } = running.modeRelaunch;
635
+ delete running.modeRelaunch;
636
+ running.parkRequested = false;
637
+ running.session = null;
638
+ running.costBaseUsd = running.costUsd; // the next process starts from here
639
+ const mode = running.mode;
640
+ // «Your conversation is kept» is only true when there IS one to keep
641
+ // (QA-128): a session switched before its first turn has no provider
642
+ // session id, so the new process starts the conversation over — and
643
+ // saying otherwise is a promise the feed can be checked against.
644
+ const kept = Boolean(running.descriptor.providerSessionId);
645
+ this.sendEvent(running, 'notice', {
646
+ level: 'info',
647
+ text: (mode === 'full'
648
+ ? 'Switched to «Unrestricted». The agent was restarted — it no longer asks about anything. '
649
+ : 'Left «Unrestricted». The agent was restarted — permission checks are back on. ') +
650
+ (kept
651
+ ? 'Your conversation is kept; send a message to carry on.'
652
+ : 'This session had not started a conversation yet, so nothing was lost — send a message to begin.'),
653
+ });
654
+ this.sendEvent(running, 'settings', { mode });
655
+ // Empty prompt: the agent boots, reports its capabilities and waits, the
656
+ // same as a free CHAT session. It must NOT start a turn of its own here.
657
+ if (this.launchAgent(running, '', running.descriptor.providerSessionId)) {
658
+ // Anything typed during the park window is waiting on disk (see
659
+ // `deliverMessage`), and the new process is the one that can take it.
660
+ this.flushPendingMessages(running);
661
+ if (priorStatus === 'REVIEW') {
662
+ this.reportStatus(descriptor.id, 'REVIEW', {
663
+ costUsd: running.costUsd,
664
+ activeMs: running.activeMs,
665
+ });
666
+ }
667
+ return;
668
+ }
669
+ // The agent did not start — an exhausted budget is the only way here. The
670
+ // session stays parked and resumable rather than silently disappearing.
671
+ this.reportStatus(descriptor.id, statusForReport(running), {
672
+ costUsd: running.costUsd,
673
+ activeMs: running.activeMs,
674
+ });
675
+ this.drainSessionsWaitingForCapacity();
676
+ return;
677
+ }
629
678
  if (running.stopRequested) {
630
679
  // Report before removing from the map — reportStatus records
631
680
  // lastReported on the live entry, and the journal cleanup below
@@ -1373,12 +1422,22 @@ export class Supervisor {
1373
1422
  this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
1374
1423
  }
1375
1424
  };
1376
- if (running.session) {
1425
+ if (running.session && !running.parkRequested) {
1377
1426
  running.session.send(text);
1378
1427
  settle();
1379
1428
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
1380
1429
  return;
1381
1430
  }
1431
+ // The process is on its way out and `running.session` has not been cleared
1432
+ // yet — parking only ASKS it to stop (QA-128). Handing the text to a dying
1433
+ // process and then reporting it delivered is the one outcome worse than
1434
+ // making the user wait: the words are gone and the interface says they
1435
+ // arrived. This window is short but it is exactly when somebody types,
1436
+ // because they have just changed the mode.
1437
+ if (running.parkRequested) {
1438
+ this.requeue(running, held, text, originSeq);
1439
+ return;
1440
+ }
1382
1441
  // Parked session: the follow-up message becomes the resume prompt.
1383
1442
  if (!this.ensureCapacity(running.descriptor.id)) {
1384
1443
  this.sendEvent(running, 'system_note', {
@@ -1573,6 +1632,22 @@ export class Supervisor {
1573
1632
  const running = this.sessions.get(sessionId);
1574
1633
  if (!running)
1575
1634
  return;
1635
+ // Validated HERE, before anything is remembered (QA-128). `running.mode` is
1636
+ // what a parked session launches with and what the API is told the session
1637
+ // is in, so storing a mode this workspace forbids made a parked session
1638
+ // report itself as «Unrestricted» and made the adapter refuse it again on
1639
+ // every single relaunch — a red line in the feed with no user action behind
1640
+ // it. The adapters keep their own guard; this one stops the value from ever
1641
+ // being written down.
1642
+ if (mode && !availableModes(running.descriptor.workspace.trustMode).includes(mode)) {
1643
+ this.sendEvent(running, 'notice', { level: 'warn', text: MODE_REFUSED_TEXT });
1644
+ this.sendEvent(running, 'settings', { mode: running.mode });
1645
+ mode = undefined;
1646
+ if (model === undefined && effort === undefined)
1647
+ return;
1648
+ }
1649
+ // Kept so a refused switch can put the picker back where it was.
1650
+ const previousMode = running.mode;
1576
1651
  // Remember first: a parked session applies them on its next launch.
1577
1652
  if (model)
1578
1653
  running.model = model;
@@ -1586,6 +1661,67 @@ export class Supervisor {
1586
1661
  this.sendEvent(running, 'settings', { model, mode, effort });
1587
1662
  return;
1588
1663
  }
1664
+ // «Unrestricted» is the one mode the agent cannot be talked into on a
1665
+ // process that was not launched for it (ticket #156). The CLI refuses the
1666
+ // control request outright — `Cannot set permission mode to
1667
+ // bypassPermissions because the session was not launched with
1668
+ // --dangerously-skip-permissions` — and until this branch existed that
1669
+ // refusal became a `notice` in the catch below, leaving the picker reading
1670
+ // «Unrestricted» over a session that went on asking.
1671
+ const needsRelaunch = Boolean(mode && running.session.modeSwitchNeedsRelaunch(mode));
1672
+ if (needsRelaunch && this.isMidTurn(running)) {
1673
+ // A new process would take the turn — and the open card, and the parked
1674
+ // question — down with it. The dashboard locks the picker while the agent
1675
+ // RUNS, but a session sitting on a permission card is WAITING_PERMISSION
1676
+ // and the picker is live there by design (that is the very moment somebody
1677
+ // reaches for it), so this guard is reachable through the interface and
1678
+ // not only through a direct API call (QA-128).
1679
+ this.sendEvent(running, 'notice', {
1680
+ level: 'warn',
1681
+ // Both directions: getting OUT of «Unrestricted» needs a new process
1682
+ // just as much as getting in, and a sentence written for one of them
1683
+ // reads as nonsense during the other.
1684
+ text: (mode === 'full'
1685
+ ? 'Switching to «Unrestricted» starts a new agent process, so it can only be done between turns. '
1686
+ : 'Leaving «Unrestricted» starts a new agent process, so it can only be done between turns. ') +
1687
+ (running.openQuestions.size > 0 || running.lastReported === 'WAITING_PERMISSION'
1688
+ ? 'Answer what the agent is asking first, or press Stop.'
1689
+ : 'Stop the turn, or wait for it to finish.'),
1690
+ });
1691
+ running.mode = previousMode;
1692
+ this.sendEvent(running, 'settings', { mode: previousMode });
1693
+ // The rest of the request still stands — refusing the mode is no reason
1694
+ // to drop a model or effort change that travelled with it (QA-128).
1695
+ await this.applyLiveSettings(running, model, undefined, effort);
1696
+ return;
1697
+ }
1698
+ if (needsRelaunch && mode) {
1699
+ await this.applyLiveSettings(running, model, undefined, effort);
1700
+ // Handed to `pumpEvents` rather than done here, exactly like the auth and
1701
+ // stale-resume recoveries above it (QA-128). `park()` only ASKS the
1702
+ // process to stop; the cleanup that follows — settling the active clock,
1703
+ // clearing the budget timers, moving the cost baseline so the next
1704
+ // process does not re-count what this one spent — happens when the event
1705
+ // stream ends. Relaunching inline wins the race against all of it: the
1706
+ // new session lands in `running.session` first, and the old pump then
1707
+ // sees `running.session !== session` and returns without cleaning up.
1708
+ running.modeRelaunch = { priorStatus: running.lastReported };
1709
+ this.park(running, { quiet: true });
1710
+ return;
1711
+ }
1712
+ await this.applyLiveSettings(running, model, mode, effort);
1713
+ }
1714
+ /** Is a turn (or a question the agent is parked on) in flight right now? */
1715
+ isMidTurn(running) {
1716
+ return (running.lastReported === 'RUNNING' ||
1717
+ running.lastReported === 'STARTING' ||
1718
+ running.lastReported === 'WAITING_PERMISSION' ||
1719
+ running.openQuestions.size > 0);
1720
+ }
1721
+ /** The three live setters, in the order that lets an explicit pick win. */
1722
+ async applyLiveSettings(running, model, mode, effort) {
1723
+ if (!running.session)
1724
+ return;
1589
1725
  try {
1590
1726
  // Model first: switching models can invalidate the picked effort, and
1591
1727
  // the adapter drops it in that case — applying effort after lets an
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.29.0";
1
+ export declare const RUNNER_VERSION = "0.30.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.29.0';
2
+ export const RUNNER_VERSION = '0.30.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",