@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.
@@ -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
@@ -135,10 +135,26 @@ class CodexSession {
135
135
  threadId = null;
136
136
  threadModel = null;
137
137
  activeTurnId = null;
138
+ /**
139
+ * Last turn this thread finished (ticket #126).
140
+ *
141
+ * `thread/fork { lastTurnId }` forks THROUGH this turn inclusive, dropping
142
+ * everything after it — which is exactly "rewind the conversation to here".
143
+ * Verified against the JSON schema the installed codex-cli 0.145.0 generates
144
+ * itself (`codex app-server generate-json-schema`): `lastTurnId` is part of
145
+ * the STABLE surface, not the experimental one.
146
+ *
147
+ * `thread/rollback` is deliberately not used: its own schema marks it
148
+ * "DEPRECATED: will be removed soon", it counts turns from the end rather
149
+ * than naming one, and it mutates the thread in place instead of forking.
150
+ */
151
+ lastCompletedTurnId = null;
138
152
  /** Plan card waiting for the user; approving it starts the real work. */
139
153
  heldPlan = null;
140
154
  lastCollabMode = null;
141
155
  mode;
156
+ /** A launch-time `full` this STRICT workspace does not allow (ticket #156). */
157
+ modeRefusedAtLaunch = false;
142
158
  model;
143
159
  effort;
144
160
  /** Last model catalogue from `model/list` — effort sets differ per model. */
@@ -150,7 +166,13 @@ class CodexSession {
150
166
  constructor(spec, home, deps) {
151
167
  this.spec = spec;
152
168
  this.home = home;
153
- 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;
154
176
  this.model = spec.model;
155
177
  this.effort = spec.effort;
156
178
  this.repairHome = deps.repairHome ?? (deps.codexHome ? null : repairCodexAuth);
@@ -169,6 +191,10 @@ class CodexSession {
169
191
  cwd: spec.cwd,
170
192
  ...wiring,
171
193
  });
194
+ if (this.modeRefusedAtLaunch) {
195
+ this.notice('warn', MODE_REFUSED_TEXT);
196
+ this.emit({ type: 'settings', mode: this.mode });
197
+ }
172
198
  void this.boot();
173
199
  }
174
200
  // ─── Boot ──────────────────────────────────────────────────────────
@@ -256,6 +282,19 @@ class CodexSession {
256
282
  * second; losing the conversation costs the whole context.
257
283
  */
258
284
  static RESUME_RETRY_DELAY_MS = 1_500;
285
+ /** Branch the thread at `lastTurnId`, dropping every later turn. */
286
+ async forkThread(threadId, lastTurnId) {
287
+ const result = asRecord(await this.client.request('thread/fork', {
288
+ threadId,
289
+ lastTurnId,
290
+ ...this.threadParams(),
291
+ }));
292
+ const thread = asRecord(result['thread']);
293
+ const id = str(thread['id']);
294
+ if (!id)
295
+ throw new Error('thread/fork returned no thread id');
296
+ return { id, model: str(result['model']) ?? null };
297
+ }
259
298
  async resumeThread(resumeId) {
260
299
  // Resume takes the SAME overrides as start, and it must: the per-thread MCP
261
300
  // overlay lives only in memory, so resuming with just a thread id brings the
@@ -273,6 +312,34 @@ class CodexSession {
273
312
  }
274
313
  async openThread() {
275
314
  const resumeId = this.spec.resumeProviderSessionId;
315
+ // Ticket #126, conversation rewind. Forking rather than resuming: the
316
+ // thread the user rewound away from stays on disk untouched, and the fork
317
+ // carries the same overrides every other entry point passes (the per-thread
318
+ // MCP overlay lives only in memory, so a thread opened without it comes
319
+ // back with no DevBridge access at all).
320
+ if (resumeId && this.spec.resumeAtAnchor) {
321
+ try {
322
+ return await this.forkThread(resumeId, this.spec.resumeAtAnchor);
323
+ }
324
+ catch (error) {
325
+ log.warn('codex: thread/fork failed — resuming the whole conversation', {
326
+ sessionId: this.spec.sessionId,
327
+ error: describe(error),
328
+ });
329
+ // Reported through the SAME channel as Claude's refused resume, so the
330
+ // supervisor withdraws the pending feed cut here too. A notice alone
331
+ // left the page believing a rewind had happened: it had already thrown
332
+ // away messages this thread still holds.
333
+ this.emit({
334
+ type: 'error',
335
+ message: 'The conversation could not be rewound to that point — the agent still remembers everything after it',
336
+ code: 'rewind_failed',
337
+ // This thread carries on below with a plain resume — there is
338
+ // nothing for the supervisor to relaunch.
339
+ recovered: true,
340
+ });
341
+ }
342
+ }
276
343
  if (resumeId) {
277
344
  try {
278
345
  return await this.resumeThread(resumeId);
@@ -535,10 +602,28 @@ class CodexSession {
535
602
  * tool call, so the next one already sees it.
536
603
  */
537
604
  setWorkspacePolicy(policy) {
605
+ const trustChanged = policy.trustMode !== undefined && policy.trustMode !== this.spec.trustMode;
538
606
  if (policy.trustMode !== undefined)
539
607
  this.spec.trustMode = policy.trustMode;
540
608
  if (policy.agentAutoCommit !== undefined)
541
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();
542
627
  }
543
628
  async setEffort(effort) {
544
629
  if (effort)
@@ -548,9 +633,56 @@ class CodexSession {
548
633
  this.emit({ type: 'settings', effort });
549
634
  this.refreshCapabilities();
550
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
+ }
551
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
+ }
552
650
  this.mode = mode;
553
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
+ }
554
686
  }
555
687
  async interrupt() {
556
688
  if (!this.threadId || !this.activeTurnId)
@@ -567,6 +699,33 @@ class CodexSession {
567
699
  log.warn('codex: interrupt failed', { error: describe(error) });
568
700
  }
569
701
  }
702
+ conversationAnchor() {
703
+ return this.lastCompletedTurnId;
704
+ }
705
+ /**
706
+ * `thread/compact/start` — a stable method of app-server 0.145.0, taking only
707
+ * the thread id and answering with an empty object; the summary itself
708
+ * arrives later as a `contextCompaction` item, which this adapter already
709
+ * turns into a notice.
710
+ *
711
+ * Until now compaction on this agent was entirely the agent's own business
712
+ * and the dashboard hid `/compact` from Codex sessions on the grounds that
713
+ * "that agent does not have it". It does.
714
+ */
715
+ async compact() {
716
+ if (!this.threadId || this.stopped)
717
+ return false;
718
+ if (this.activeTurnId)
719
+ return false;
720
+ try {
721
+ await this.client.request('thread/compact/start', { threadId: this.threadId });
722
+ return true;
723
+ }
724
+ catch (error) {
725
+ log.warn('codex: compaction refused', { error: describe(error) });
726
+ return false;
727
+ }
728
+ }
570
729
  stop(reason = 'session_stopped') {
571
730
  if (this.stopped)
572
731
  return;
@@ -623,6 +782,9 @@ class CodexSession {
623
782
  ? { decision: 'ask', reason: 'details unavailable' }
624
783
  : evaluateToolUse(enriched.policyTool, enriched.policyInput, {
625
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,
626
788
  ...(this.spec.agentAutoCommit === undefined
627
789
  ? {}
628
790
  : { agentAutoCommit: this.spec.agentAutoCommit }),
@@ -643,7 +805,11 @@ class CodexSession {
643
805
  });
644
806
  return;
645
807
  }
646
- 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
+ });
647
813
  this.emit({
648
814
  type: 'permission',
649
815
  requestId,
@@ -1140,6 +1306,12 @@ class CodexSession {
1140
1306
  onTurnCompleted(params) {
1141
1307
  const turn = asRecord(params['turn']);
1142
1308
  const status = str(turn['status']);
1309
+ // Ticket #126: the anchor a conversation rewind is measured from. Only a
1310
+ // COMPLETED turn may be one — `thread/fork` refuses a turn that is still in
1311
+ // progress, and the id is taken from the notification rather than from
1312
+ // `activeTurnId` so a steered turn anchors on what the thread actually
1313
+ // recorded.
1314
+ this.lastCompletedTurnId = str(turn['id']) ?? this.activeTurnId ?? this.lastCompletedTurnId;
1143
1315
  this.activeTurnId = null;
1144
1316
  // A held plan means the turn ended by proposing, not by finishing the work.
1145
1317
  if (this.heldPlan)
@@ -1153,8 +1325,14 @@ class CodexSession {
1153
1325
  });
1154
1326
  return;
1155
1327
  }
1156
- // `interrupted` is a user-initiated Stop: the session stays usable.
1157
- this.emit({ type: 'turn_end', ok: true });
1328
+ // `interrupted` is a user-initiated Stop: the session stays usable. Named
1329
+ // rather than merely tolerated, so the feed can tell "stopped" from
1330
+ // "finished" — the same distinction the Claude adapter now draws.
1331
+ this.emit({
1332
+ type: 'turn_end',
1333
+ ok: true,
1334
+ ...(status === 'interrupted' ? { aborted: true } : {}),
1335
+ });
1158
1336
  this.flushQueued();
1159
1337
  }
1160
1338
  onStderr(text) {
@@ -1201,7 +1379,10 @@ class CodexSession {
1201
1379
  const currentEffort = this.effort ?? models.find((m) => m.id === currentModel)?.defaultEffort ?? undefined;
1202
1380
  const capabilities = {
1203
1381
  models,
1204
- 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),
1205
1386
  commands,
1206
1387
  currentMode: this.mode,
1207
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
@@ -146,6 +180,21 @@ export interface SessionSpec {
146
180
  model?: string;
147
181
  effort?: string;
148
182
  resumeProviderSessionId?: string;
183
+ /**
184
+ * Resume the conversation only up to this anchor, dropping everything after
185
+ * it (ticket #126, conversation rewind).
186
+ *
187
+ * The value is whatever `AgentSession.conversationAnchor()` returned earlier
188
+ * in this session's life — a Claude message uuid or a Codex turn id. It is
189
+ * opaque to everything above the adapter, and it is only ever meaningful
190
+ * together with `resumeProviderSessionId`.
191
+ *
192
+ * Both agents answer this by FORKING rather than by truncating in place, so
193
+ * the original conversation is never destroyed: the adapter reports the new
194
+ * provider session id through `provider_session`, and the old one stays on
195
+ * disk exactly as it was.
196
+ */
197
+ resumeAtAnchor?: string;
149
198
  mcp?: McpConfig;
150
199
  maxBudgetUsd?: number;
151
200
  }
@@ -313,6 +362,14 @@ export type AgentEvent = {
313
362
  type: 'turn_end';
314
363
  ok: boolean;
315
364
  errorMessage?: string;
365
+ /**
366
+ * The turn ended because the user pressed Stop, not because it finished.
367
+ *
368
+ * Reported `ok: true` — a stop is not a failure — but named, so the feed
369
+ * can say «stopped» rather than «done» and so a stopped turn never reads
370
+ * as work the agent completed.
371
+ */
372
+ aborted?: boolean;
316
373
  } | {
317
374
  type: 'error';
318
375
  message: string;
@@ -323,7 +380,23 @@ export type AgentEvent = {
323
380
  * never signed in "expired" sent people looking for a problem that did
324
381
  * not exist.
325
382
  */
326
- code?: 'resume_failed' | 'auth_expired' | 'auth_missing';
383
+ /**
384
+ * `rewind_failed` — the CLI refused the point we asked it to resume at
385
+ * (ticket #126). Recoverable and NOT a failed session: the conversation
386
+ * is intact, only the rewind did not happen, and the caller must say so
387
+ * rather than pretending it did.
388
+ */
389
+ code?: 'resume_failed' | 'auth_expired' | 'auth_missing' | 'rewind_failed';
390
+ /**
391
+ * The adapter has already dealt with it and the session is still running.
392
+ *
393
+ * Only meaningful with `rewind_failed`, and it is the difference between
394
+ * the two agents: Claude's refused resume kills the query, so the
395
+ * supervisor must bring the process back without the anchor; Codex falls
396
+ * back to a plain resume inside `openThread` and needs no relaunch at
397
+ * all. Both still have to withdraw the feed cut and say what happened.
398
+ */
399
+ recovered?: boolean;
327
400
  };
328
401
  export interface AgentSession {
329
402
  /** Ends when the underlying agent process is gone. */
@@ -349,6 +422,24 @@ export interface AgentSession {
349
422
  setEffort(effort: string | null): Promise<void>;
350
423
  /** Switch interaction mode mid-session. */
351
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;
352
443
  /**
353
444
  * Project-level policy changed while this session is running (session 15).
354
445
  *
@@ -365,6 +456,26 @@ export interface AgentSession {
365
456
  }): void;
366
457
  /** Interrupt the current turn (session stays resumable). */
367
458
  interrupt(): Promise<void>;
459
+ /**
460
+ * An opaque id naming the conversation as it stands RIGHT NOW (ticket #126).
461
+ *
462
+ * Handed back later as `SessionSpec.resumeAtAnchor` to rewind the agent's
463
+ * memory to this exact point. `null` means the agent has nothing to anchor to
464
+ * yet — a session whose first turn has not finished — and the caller must
465
+ * then treat "rewind to here" as "start the conversation over".
466
+ *
467
+ * Claude: the uuid of the last message in its transcript.
468
+ * Codex: the id of the last completed turn.
469
+ */
470
+ conversationAnchor(): string | null;
471
+ /**
472
+ * Ask the agent to summarise the conversation so far and continue from the
473
+ * summary — `/compact` in either CLI.
474
+ *
475
+ * Returns false when the agent is in no state to do it (mid-turn, or the
476
+ * call was refused); the caller says so rather than pretending it happened.
477
+ */
478
+ compact(): Promise<boolean>;
368
479
  /**
369
480
  * Tear the session down (kills the agent process). The reason travels so
370
481
  * anything the user was still being asked is withdrawn with a cause they can
@@ -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
@@ -67,6 +67,17 @@ export declare function saveAttachments(input: {
67
67
  saved: SavedAttachment[];
68
68
  failed: string[];
69
69
  }>;
70
+ /**
71
+ * Delete old attachments from a session worktree.
72
+ *
73
+ * These files are invisible to git by design, which also means nothing else
74
+ * will ever clean them up: a long-lived workspace would accumulate every
75
+ * screenshot and archive anyone attached to it, on the owner's own disk. Age
76
+ * first, then a size budget for the case where age alone is not enough.
77
+ *
78
+ * Best effort — a folder we cannot prune is not a reason to lose the message.
79
+ */
80
+ export declare function pruneAttachmentDir(dir: string, now?: number): void;
70
81
  /**
71
82
  * Turn the user's message plus the files into one prompt.
72
83
  *
@@ -74,6 +85,22 @@ export declare function saveAttachments(input: {
74
85
  * files with their own tools, and an agent-specific encoding (image blocks for
75
86
  * Claude, `localImage` items for Codex) would be two code paths that drift.
76
87
  * The user's own words stay first — the files are context, not the request.
88
+ *
89
+ * Each line NAMES the file and stops there (#128). The old version ended every
90
+ * list with «Open them before answering — images included», which was false for
91
+ * a `.zip` — there is nothing to look at — and false for a `.docx`. An
92
+ * instruction that is wrong for the file in front of the agent is worse than no
93
+ * instruction: it produces a confident answer about a document nobody opened.
94
+ *
95
+ * What we deliberately do NOT do is decide for the agent. Unpacking an archive,
96
+ * or extracting a document's text and handing over our version of it, would put
97
+ * DevBridge in charge of a job the agent does better with the whole file in
98
+ * front of it — and would mean the agent answers about what WE chose to show,
99
+ * not about what the person actually attached.
100
+ *
101
+ * The closing line is a trust frame, not decoration. These files come from a
102
+ * person through a web form; anything inside one that reads like an order to
103
+ * the agent is data, not authority (react-security-standards AI.2/AI.3).
77
104
  */
78
105
  export declare function composeMessageWithAttachments(text: string, saved: SavedAttachment[]): string;
79
106
  //# sourceMappingURL=attachments.d.ts.map