@bridge4dev/runner 0.60.0 → 0.62.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/README.md CHANGED
@@ -49,12 +49,18 @@ it. If you would rather run it in the foreground, or under your own supervisor,
49
49
 
50
50
  ## What it does on your machine
51
51
 
52
- - Creates a **git worktree and a branch per session**, so an agent never works in your
53
- working copy and never touches your current branch.
54
- - Enforces a local policy layer the dashboard **cannot** raise: an allow/deny list for
55
- commands, secret masking in everything it streams, and its own ceiling on how many
56
- sessions may run at once. Anything the server asks for is bounded by what this config
57
- permits.
52
+ - Runs a session either **in the project folder itself** — the default: the branch it is
53
+ already on, nothing to apply afterwards — or in a **git worktree on a session branch of
54
+ its own**, which DevBridge asks for when the folder is busy or a branch was named. Which
55
+ one a session gets is decided per session, in the dashboard.
56
+ - Enforces a local policy layer. Part of it the dashboard **cannot** raise: secret files and
57
+ keys are never read, nothing is written into any repository's `.git`, and `sudo`, service
58
+ control, docker and the firewall are refused in every trust level. Part of it is the bound
59
+ project's own setting, chosen by a manager in DevBridge: whether the agent may `git push`
60
+ and to which branches, and whether it may read and change files **outside the project
61
+ folder** — other projects on the same machine included. Out of the box the agent pushes
62
+ nothing and stays in its folder. Plus secret masking in everything it streams, and its own
63
+ ceiling on how many sessions may run at once.
58
64
  - Streams the session journal with sequence numbers and acknowledgements, so a restart of
59
65
  either side resumes instead of losing work.
60
66
  - Never uploads your source. Diffs and file views are requested per file, and files
@@ -8,7 +8,7 @@ import { mcpConfigPath } from '../paths.js';
8
8
  import { lowerPriority } from '../process-priority.js';
9
9
  import { cageSpawn, noteSessionAgentPid, memoryDeathSentence, releaseSessionScope, sessionMemoryEnv, sessionMemoryPromptLine, } from '../session-cage.js';
10
10
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
- import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
11
+ import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
13
13
  import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
14
14
  import { assertClaudeInstalled, claudeExecutableOption, sessionClaudePath, } from '../agent-binary.js';
@@ -166,14 +166,19 @@ const MODE_TO_PERMISSION = {
166
166
  * no line: the agent plans around a restriction that is not there, or walks
167
167
  * into one it was told did not exist.
168
168
  */
169
- function systemAppendFor(spec) {
169
+ function systemAppendFor(spec, mode) {
170
170
  const pushBanned = spec.gitPolicy?.agentPushBan !== false;
171
+ // #418: which folder line this session gets, if any — see `folderRuleFor`.
172
+ const folderRule = folderRuleFor(spec, mode);
171
173
  const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
172
174
  const memoryLine = sessionMemoryPromptLine(spec.sessionId);
173
175
  return [
174
176
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
175
177
  'Rules:',
176
- '- Work ONLY inside the current working directory.',
178
+ // #418. The three cases live in `folderRuleFor` — one copy for both
179
+ // adapters, and the Codex adapter asks the same function whether a mode
180
+ // switch would change what the agent was told.
181
+ ...(folderRule === null ? [] : [folderRule]),
177
182
  // Session 13: pushing was refused outright by layer 1 in every trust mode,
178
183
  // and saying so here saved the agent a turn spent discovering it. Session
179
184
  // 18 makes it the project's decision — so the sentence has to follow the
@@ -203,8 +208,8 @@ function systemAppendFor(spec) {
203
208
  ].join('\n');
204
209
  }
205
210
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
206
- function composeSystemAppend(spec) {
207
- const base = systemAppendFor(spec);
211
+ function composeSystemAppend(spec, mode) {
212
+ const base = systemAppendFor(spec, mode);
208
213
  return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
209
214
  }
210
215
  /**
@@ -585,7 +590,7 @@ class ClaudeSession {
585
590
  systemPrompt: {
586
591
  type: 'preset',
587
592
  preset: 'claude_code',
588
- append: composeSystemAppend(spec),
593
+ append: composeSystemAppend(spec, this.mode),
589
594
  },
590
595
  canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
591
596
  // Ticket #113: a running subagent forks its own conversation every ~30s
@@ -2180,6 +2185,65 @@ class ClaudeSession {
2180
2185
  conversationAnchor() {
2181
2186
  return this.lastMessageUuid;
2182
2187
  }
2188
+ /** A compaction the CLI has announced and not yet ended (#348). */
2189
+ compacting = false;
2190
+ /**
2191
+ * `system:status` — the CLI's own word on what it is doing (#348).
2192
+ *
2193
+ * Read the way the CLI's own remote client reads it (`noteInbound`):
2194
+ * «compacting» opens a compaction, `requesting` says nothing about one (the
2195
+ * summary itself is a model request, and this status arrives DURING a
2196
+ * compaction), and any other status closes it — with `compact_result` when
2197
+ * the CLI says how it went, and as «skipped» when it does not: a PreCompact
2198
+ * hook that said no, or a reactive compaction that found nothing to fold,
2199
+ * both end on a bare `status: null`. Left unclosed, that start line would
2200
+ * keep the dashboard saying «compacting» until the turn ended, minutes later.
2201
+ *
2202
+ * De-duplicated here as well as in the supervisor: the CLI re-says
2203
+ * «compacting» every thirty seconds while it waits on a precomputed summary.
2204
+ *
2205
+ * No `standalone` on this agent: `/compact` is a turn, and its `result`
2206
+ * settles the status the ordinary way.
2207
+ */
2208
+ noteStatus(msg) {
2209
+ const status = msg.status;
2210
+ if (status === 'requesting')
2211
+ return;
2212
+ if (status === 'compacting') {
2213
+ if (this.compacting)
2214
+ return;
2215
+ this.compacting = true;
2216
+ this.emit({ type: 'compaction', phase: 'started' });
2217
+ return;
2218
+ }
2219
+ // An end is only an end of something that began: without a start on
2220
+ // record there is nothing to close, and the second of two endings (a
2221
+ // `compact_result` after a `compact_boundary`) must not open a second
2222
+ // pair. A mode change also travels on this message — `{status: null,
2223
+ // permissionMode}` on every `setPermissionMode` in CLI 2.1.271 — and says
2224
+ // nothing about a compaction at all.
2225
+ if (!this.compacting)
2226
+ return;
2227
+ const compactResult = msg.compact_result;
2228
+ if (compactResult === undefined && msg.permissionMode !== undefined)
2229
+ return;
2230
+ this.compacting = false;
2231
+ if (compactResult === 'failed') {
2232
+ this.emit({
2233
+ type: 'compaction',
2234
+ phase: 'finished',
2235
+ ok: false,
2236
+ error: maskString(String(msg.compact_error ?? 'unknown')).slice(0, 300),
2237
+ });
2238
+ return;
2239
+ }
2240
+ this.emit({
2241
+ type: 'compaction',
2242
+ phase: 'finished',
2243
+ ok: true,
2244
+ ...(compactResult === 'success' ? {} : { skipped: true }),
2245
+ });
2246
+ }
2183
2247
  /**
2184
2248
  * `/compact` — the CLI's own command, delivered as ordinary user input.
2185
2249
  *
@@ -2296,19 +2360,15 @@ class ClaudeSession {
2296
2360
  this.refreshUsage();
2297
2361
  }
2298
2362
  else if (msg.subtype === 'status') {
2299
- const status = msg.status;
2300
- const compactResult = msg.compact_result;
2301
- if (status === 'compacting') {
2302
- this.emit({ type: 'notice', level: 'info', text: 'Compacting the conversation…' });
2303
- }
2304
- else if (compactResult) {
2305
- this.emit({
2306
- type: 'notice',
2307
- level: compactResult === 'failed' ? 'warn' : 'info',
2308
- text: compactResult === 'failed'
2309
- ? `Compaction failed: ${maskString(String(msg.compact_error ?? 'unknown')).slice(0, 300)}`
2310
- : 'Conversation compacted',
2311
- });
2363
+ this.noteStatus(msg);
2364
+ }
2365
+ else if (msg.subtype === 'compact_boundary') {
2366
+ // The summary is in place: this marker is the end of a
2367
+ // compaction whether or not a status said so first (#348) — the
2368
+ // CLI's own remote client reads it the same way.
2369
+ if (this.compacting) {
2370
+ this.compacting = false;
2371
+ this.emit({ type: 'compaction', phase: 'finished', ok: true });
2312
2372
  }
2313
2373
  }
2314
2374
  else if (msg.subtype === 'api_retry') {
@@ -2440,6 +2500,10 @@ class ClaudeSession {
2440
2500
  break;
2441
2501
  }
2442
2502
  case 'result': {
2503
+ // A turn's end is the end of any compaction it carried (#348):
2504
+ // the supervisor closes the pair on `turn_end`, and the flag here
2505
+ // has to agree, or the next «compacting» would be read as a repeat.
2506
+ this.compacting = false;
2443
2507
  this.emit({
2444
2508
  type: 'cost',
2445
2509
  costUsd: msg.total_cost_usd,
@@ -6,7 +6,7 @@ import { RUNNER_VERSION } from '../version.js';
6
6
  import { repairCodexAuth } from './codex-home.js';
7
7
  import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
8
8
  import { truncate } from './claude.js';
9
- import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
+ import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
10
10
  import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
11
11
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
12
12
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
@@ -60,6 +60,37 @@ const MODE_POLICY = {
60
60
  // exactly like Claude's bypassPermissions. The dashboard says so.
61
61
  full: { approvalPolicy: 'never', sandbox: 'danger-full-access', plan: false },
62
62
  };
63
+ /**
64
+ * Where a `workspace-write` turn may write (#418).
65
+ *
66
+ * Codex confines writes in the KERNEL, not in our policy: under `auto` the
67
+ * sandbox is `workspace-write` with `writableRoots: [cwd]`, so a write outside
68
+ * fails inside the CLI, comes back as a «retry without sandbox?» approval, and
69
+ * only then reaches layer 1. With the project's permission on, that round trip
70
+ * is exactly the refusal the ticket exists to remove — so the ROOT is widened.
71
+ *
72
+ * **The sandbox is widened, never dropped**, and that is the whole safety of
73
+ * this function. `danger-full-access` looks like the simpler answer and is the
74
+ * wrong one: for Codex the kernel sandbox refusing something IS the only
75
+ * channel that reaches `evaluateToolUse` (the adapter calls it from
76
+ * `onApproval` and nowhere else). Remove the sandbox and nothing ever
77
+ * escalates, so `sudo`, docker, the firewall, secret paths, any repository's
78
+ * `.git`, the project's prompt file and the project's own git policy would all
79
+ * stop being enforced for Codex — the very list the setting promises to keep.
80
+ * Keeping `workspaceWrite` keeps that channel, and keeps `networkAccess: false`
81
+ * with it, so the permission moves one boundary and no other.
82
+ *
83
+ * **STRICT does not widen.** There a step outside must still raise a card, and
84
+ * the only way to raise one for Codex is to let the sandbox refuse it first.
85
+ *
86
+ * Read on every turn rather than captured at launch, because the setting can be
87
+ * switched while the session runs.
88
+ */
89
+ function writableRootsFor(spec) {
90
+ if (spec.trustMode === 'STRICT')
91
+ return [spec.cwd];
92
+ return spec.gitPolicy?.agentAllowOutsideFolder === true ? ['/'] : [spec.cwd];
93
+ }
63
94
  /**
64
95
  * DevBridge's own rules — composed per session since session 18, and kept
65
96
  * deliberately in step with `systemAppendFor` in the Claude adapter.
@@ -69,14 +100,19 @@ const MODE_POLICY = {
69
100
  * `workMode: DIRECT` (the default since session 16), and the push sentence is
70
101
  * only true when the project has «Принудительно запретить push» switched on.
71
102
  */
72
- function systemAppendFor(spec) {
103
+ function systemAppendFor(spec, mode) {
73
104
  const pushBanned = spec.gitPolicy?.agentPushBan !== false;
105
+ // #418: which folder line this session gets, if any — see `folderRuleFor`.
106
+ const folderRule = folderRuleFor(spec, mode);
74
107
  const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
75
108
  const memoryLine = sessionMemoryPromptLine(spec.sessionId);
76
109
  return [
77
110
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
78
111
  'Rules:',
79
- '- Work ONLY inside the current working directory.',
112
+ // #418. The three cases live in `folderRuleFor` — one copy for both
113
+ // adapters, and the Codex adapter asks the same function whether a mode
114
+ // switch would change what the agent was told.
115
+ ...(folderRule === null ? [] : [folderRule]),
80
116
  pushBanned
81
117
  ? '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked for this project. A human presses «Push» and «Apply» in DevBridge when the branch is ready.'
82
118
  : guarded.length > 0
@@ -96,8 +132,8 @@ function systemAppendFor(spec) {
96
132
  ].join('\n');
97
133
  }
98
134
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
99
- function composeSystemAppend(spec) {
100
- const base = systemAppendFor(spec);
135
+ function composeSystemAppend(spec, mode) {
136
+ const base = systemAppendFor(spec, mode);
101
137
  return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
102
138
  }
103
139
  // Allowlist, not denylist: whatever secrets live in the daemon's environment
@@ -534,8 +570,14 @@ class CodexSession {
534
570
  return {
535
571
  cwd: this.spec.cwd,
536
572
  approvalPolicy: policy.approvalPolicy,
573
+ // #418 does NOT change this one, and that is deliberate. The thread-level
574
+ // channel takes the CLI-style MODE string, which has no way to say «this
575
+ // root»; the mode itself is unchanged (`workspace-write` stays
576
+ // `workspace-write`), and the roots travel with every turn below. Both
577
+ // channels therefore still describe the same sandbox, which is what R10
578
+ // of the plan asks for — there is no second shape to keep in step.
537
579
  sandbox: policy.sandbox,
538
- developerInstructions: composeSystemAppend(this.spec),
580
+ developerInstructions: composeSystemAppend(this.spec, this.mode),
539
581
  ...(this.model ? { model: this.model } : {}),
540
582
  ...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
541
583
  };
@@ -582,7 +624,8 @@ class CodexSession {
582
624
  // [] is accepted, null is rejected outright.
583
625
  input: [{ type: 'text', text, text_elements: [] }],
584
626
  approvalPolicy: policy.approvalPolicy,
585
- sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec.cwd),
627
+ // #418: the roots live here and only here — see `writableRootsFor`.
628
+ sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec),
586
629
  ...(this.model ? { model: this.model } : {}),
587
630
  // "Override the reasoning effort for this turn and subsequent turns" —
588
631
  // the same sticky-override channel the model uses (there is still no
@@ -793,12 +836,29 @@ class CodexSession {
793
836
  this.refreshCapabilities();
794
837
  }
795
838
  /**
796
- * Codex needs no new process for any mode: `approvalPolicy` and the sandbox
797
- * policy travel with the next `turn/start`, so the change is in force from
798
- * the next turn whatever it is (ticket #156).
839
+ * Codex needs no new process for its POLICY: `approvalPolicy` and the sandbox
840
+ * travel with the next `turn/start`, so the change is in force from the next
841
+ * turn whatever it is (ticket #156).
842
+ *
843
+ * Its RULES are the other half, and they cannot follow (#418). DevBridge's
844
+ * own instructions reach Codex ONLY as the thread's `developerInstructions`,
845
+ * fixed when the thread opens: this protocol version has no `settings/update`,
846
+ * and putting the text on the turn instead is the duplicated-prompt defect of
847
+ * ticket #179. So a line that has to DISAPPEAR in «Unrestricted» — where
848
+ * layer 1 is never consulted and the sentence would be the only thing left
849
+ * enforcing it — can only disappear with a new thread.
850
+ *
851
+ * Asked precisely rather than by hard-coding the `full` boundary: would the
852
+ * folder line actually change? With «may work outside the project folder» ON
853
+ * the answer is never — the sentence is then the same in every mode — so
854
+ * those projects keep Codex's free, instant mode switching. Only a session
855
+ * that would be told something different pays for a new process, and it pays
856
+ * exactly what a Claude session has always paid for the same move.
799
857
  */
800
- modeSwitchNeedsRelaunch(_mode) {
801
- return false;
858
+ modeSwitchNeedsRelaunch(mode) {
859
+ if (!availableModes(this.spec.trustMode).includes(mode))
860
+ return false;
861
+ return folderRuleFor(this.spec, mode) !== folderRuleFor(this.spec, this.mode);
802
862
  }
803
863
  async setMode(mode) {
804
864
  if (!availableModes(this.spec.trustMode).includes(mode)) {
@@ -876,6 +936,7 @@ class CodexSession {
876
936
  return false;
877
937
  try {
878
938
  await this.client.request('thread/compact/start', { threadId: this.threadId });
939
+ this.requestedCompaction = true;
879
940
  return true;
880
941
  }
881
942
  catch (error) {
@@ -883,6 +944,14 @@ class CodexSession {
883
944
  return false;
884
945
  }
885
946
  }
947
+ /**
948
+ * A compaction THIS adapter asked for with `thread/compact/start` (#348) —
949
+ * the one kind that runs outside any turn and therefore has nothing to
950
+ * settle the session status behind it. The agent's own compaction inside a
951
+ * turn is not it, even when its item happens to complete before the
952
+ * `turn/started` that announces the turn.
953
+ */
954
+ requestedCompaction = false;
886
955
  stop(reason = 'session_stopped') {
887
956
  if (this.stopped)
888
957
  return;
@@ -1472,8 +1541,19 @@ class CodexSession {
1472
1541
  return;
1473
1542
  }
1474
1543
  case 'contextCompaction': {
1475
- if (done)
1476
- this.notice('info', 'Conversation compacted');
1544
+ // #348: an event, not a notice — see `AgentEvent['compaction']`. A
1545
+ // compaction started from the dashboard runs outside any turn on this
1546
+ // agent (`thread/compact/start` creates none), so nothing will settle
1547
+ // the status behind it; `standalone` tells the supervisor to.
1548
+ if (method === 'item/started') {
1549
+ this.emit({ type: 'compaction', phase: 'started' });
1550
+ return;
1551
+ }
1552
+ if (done) {
1553
+ const standalone = this.requestedCompaction && this.activeTurnId === null;
1554
+ this.requestedCompaction = false;
1555
+ this.emit({ type: 'compaction', phase: 'finished', ok: true, standalone });
1556
+ }
1477
1557
  return;
1478
1558
  }
1479
1559
  case 'webSearch':
@@ -2235,9 +2315,10 @@ class CodexSession {
2235
2315
  }
2236
2316
  class ResumeFailed extends Error {
2237
2317
  }
2238
- function sandboxPolicyFor(mode, cwd) {
2318
+ function sandboxPolicyFor(mode, spec) {
2239
2319
  // turn/start takes the structured SandboxPolicy, while thread/start takes the
2240
- // CLI-style SandboxMode string. Same intent, two shapes.
2320
+ // CLI-style SandboxMode string. Same intent, two shapes — and only this one
2321
+ // can name the writable roots, which is why #418 lives here.
2241
2322
  switch (mode) {
2242
2323
  case 'read-only':
2243
2324
  return { type: 'readOnly', networkAccess: false };
@@ -2246,7 +2327,11 @@ function sandboxPolicyFor(mode, cwd) {
2246
2327
  default:
2247
2328
  return {
2248
2329
  type: 'workspaceWrite',
2249
- writableRoots: [cwd],
2330
+ writableRoots: writableRootsFor(spec),
2331
+ // Unchanged by #418, on purpose: the permission is about the file
2332
+ // system, and letting the network out with it would be a second
2333
+ // boundary nobody asked to move. A network command still fails in the
2334
+ // sandbox, still escalates, and still ends at layer 1.
2250
2335
  networkAccess: false,
2251
2336
  excludeTmpdirEnvVar: false,
2252
2337
  excludeSlashTmp: false,
@@ -110,6 +110,7 @@ const INVALIDATION_PHRASES = {
110
110
  turn_aborted: 'the turn was interrupted',
111
111
  agent_cancelled: 'the agent withdrew it',
112
112
  budget_spent: 'the session ran out of its allowed working time',
113
+ not_held: 'the agent no longer holds this question',
113
114
  };
114
115
  /** The question was taken away from the user — say why, never fake an answer. */
115
116
  export function invalidationMessage(reason) {
@@ -322,6 +322,56 @@ export declare function cardDescription(verdict: PolicyDecision, own: string | u
322
322
  * into a card.
323
323
  */
324
324
  export declare const DIRECT_BRANCH_RULE = "- This session works directly in the project folder, which other sessions and people share. Stay on the current branch: do not `git checkout <branch>` or `git switch` here. To put a file back use `git checkout -- <path>` or `git restore <path>`.";
325
+ /**
326
+ * «Stay in this folder» — the line layer 1 used to back up (#418).
327
+ *
328
+ * Here, beside `DIRECT_BRANCH_RULE` and for the same reason: the two
329
+ * `systemAppendFor` texts are hand-synced copies of each other, and a rule that
330
+ * has to appear in one of them and not the other is the kind of thing that
331
+ * drifts silently. Exported so the tests can name the exact string rather than
332
+ * a substring of it.
333
+ *
334
+ * NOT written when the session is «Unrestricted» — there the CLI never asks
335
+ * layer 1 anything, so this sentence was the only thing left enforcing it, and
336
+ * a rule with nothing underneath is a rule the agent refuses itself by — nor
337
+ * when the project has switched «may work outside the project folder» on, where
338
+ * `outsideFolderRule` takes its place.
339
+ */
340
+ export declare const WORKING_DIRECTORY_RULE = "- Work ONLY inside the current working directory.";
341
+ /**
342
+ * What replaces the line above when the project allows work outside the folder
343
+ * (#418).
344
+ *
345
+ * It says three things and no more: where the agent is, that it may go
346
+ * elsewhere when the task needs it, and what it gives up by doing so — nothing
347
+ * outside the folder reaches «Changes», «Apply» or a restore point. That last
348
+ * half is the part the agent cannot discover for itself and the part a person
349
+ * would otherwise find out from a diff that is missing a file.
350
+ */
351
+ export declare function outsideFolderRule(cwd: string): string;
352
+ /**
353
+ * Which of the two lines above this session gets, or neither (#418).
354
+ *
355
+ * One function rather than the same three-way choice written out in both
356
+ * `systemAppendFor` texts — and it has a second caller that made it worth
357
+ * extracting: the Codex adapter asks it whether a MODE SWITCH would change what
358
+ * the agent was told, because Codex's developer instructions are fixed when the
359
+ * thread opens and cannot be edited afterwards.
360
+ *
361
+ * The three cases, in order:
362
+ * - the project allows work outside the folder → say where the folder is and
363
+ * what leaving it costs, in EVERY mode (the permission does not depend on
364
+ * the mode, so neither does the sentence);
365
+ * - «Unrestricted» → `null`, nothing at all. Layer 1 is never consulted there,
366
+ * so this line would be the only thing enforcing the rule: the agent refuses
367
+ * itself by a sentence nothing backs up, which is the complaint #418 came
368
+ * from;
369
+ * - otherwise → the rule as it has always been, with layer 1 behind it.
370
+ *
371
+ * The mode passed in must be the one the session is actually IN, not the one it
372
+ * was asked for: a `full` refused on a STRICT workspace keeps the line.
373
+ */
374
+ export declare function folderRuleFor(spec: SessionSpec, mode: AgentMode | undefined): string | null;
325
375
  /**
326
376
  * One question inside an agent's question call (session 12).
327
377
  *
@@ -366,7 +416,16 @@ export interface AgentQuestionAnswer {
366
416
  notes?: string;
367
417
  }
368
418
  /** Why a question was taken away from the user without them answering it. */
369
- export type QuestionInvalidationReason = 'session_stopped' | 'session_parked' | 'runner_restarted' | 'turn_aborted' | 'agent_cancelled' | 'budget_spent';
419
+ export type QuestionInvalidationReason = 'session_stopped' | 'session_parked' | 'runner_restarted' | 'turn_aborted' | 'agent_cancelled' | 'budget_spent'
420
+ /**
421
+ * An answer arrived for a card no live process holds (#392): the card
422
+ * outlived the process that asked, or the ask was withdrawn before the
423
+ * answer got here. The runner does not know which, and says only what it
424
+ * does know. Never handed to an agent — there is no agent holding the ask
425
+ * to hand it to — so its phrase in `INVALIDATION_PHRASES` is for the type
426
+ * checker; the words a person reads are the dashboard's.
427
+ */
428
+ | 'not_held';
370
429
  /** The dashboard's answer to one open ask. */
371
430
  export interface QuestionReply {
372
431
  askId: string;
@@ -502,6 +561,38 @@ export type AgentEvent = {
502
561
  type: 'notice';
503
562
  level: 'info' | 'warn';
504
563
  text: string;
564
+ }
565
+ /**
566
+ * The conversation is being folded into a summary, or just was (#348).
567
+ *
568
+ * Its own event and not a `notice` on purpose: adapter notices are
569
+ * de-duplicated per session on their text (gotcha §148), so «Conversation
570
+ * compacted» reached the feed once and every later compaction was silence.
571
+ * A compaction is a fact the feed has to hear every time — the dashboard's
572
+ * «compacting…» indicator is read off the feed, and a second compaction
573
+ * with no lines is a second compaction the person cannot see end.
574
+ *
575
+ * `standalone` on the ending: `true` when NO turn is going to close behind
576
+ * this compaction, so the supervisor has to settle the session status
577
+ * itself. Codex runs a compaction started from the dashboard outside any
578
+ * turn (`thread/compact/start`); Claude delivers `/compact` as a turn, and
579
+ * that turn's own ending settles the status the ordinary way.
580
+ */
581
+ | {
582
+ type: 'compaction';
583
+ phase: 'started';
584
+ } | {
585
+ type: 'compaction';
586
+ phase: 'finished';
587
+ ok: boolean;
588
+ error?: string;
589
+ standalone?: boolean;
590
+ /**
591
+ * It ended without a summary: the CLI decided not to compact after all
592
+ * (a PreCompact hook said no, or it found nothing worth folding). Not a
593
+ * failure — nothing broke — and not a compaction either.
594
+ */
595
+ skipped?: boolean;
505
596
  } | {
506
597
  type: 'cost';
507
598
  costUsd: number;
@@ -100,6 +100,65 @@ export function cardDescription(verdict, own) {
100
100
  * into a card.
101
101
  */
102
102
  export const DIRECT_BRANCH_RULE = '- This session works directly in the project folder, which other sessions and people share. Stay on the current branch: do not `git checkout <branch>` or `git switch` here. To put a file back use `git checkout -- <path>` or `git restore <path>`.';
103
+ /**
104
+ * «Stay in this folder» — the line layer 1 used to back up (#418).
105
+ *
106
+ * Here, beside `DIRECT_BRANCH_RULE` and for the same reason: the two
107
+ * `systemAppendFor` texts are hand-synced copies of each other, and a rule that
108
+ * has to appear in one of them and not the other is the kind of thing that
109
+ * drifts silently. Exported so the tests can name the exact string rather than
110
+ * a substring of it.
111
+ *
112
+ * NOT written when the session is «Unrestricted» — there the CLI never asks
113
+ * layer 1 anything, so this sentence was the only thing left enforcing it, and
114
+ * a rule with nothing underneath is a rule the agent refuses itself by — nor
115
+ * when the project has switched «may work outside the project folder» on, where
116
+ * `outsideFolderRule` takes its place.
117
+ */
118
+ export const WORKING_DIRECTORY_RULE = '- Work ONLY inside the current working directory.';
119
+ /**
120
+ * What replaces the line above when the project allows work outside the folder
121
+ * (#418).
122
+ *
123
+ * It says three things and no more: where the agent is, that it may go
124
+ * elsewhere when the task needs it, and what it gives up by doing so — nothing
125
+ * outside the folder reaches «Changes», «Apply» or a restore point. That last
126
+ * half is the part the agent cannot discover for itself and the part a person
127
+ * would otherwise find out from a diff that is missing a file.
128
+ */
129
+ export function outsideFolderRule(cwd) {
130
+ return (`- Your working directory is ${cwd}; you may read and change files elsewhere on this server ` +
131
+ `when the task needs it. Only what is inside ${cwd} shows up in the DevBridge diff and restore points.`);
132
+ }
133
+ /**
134
+ * Which of the two lines above this session gets, or neither (#418).
135
+ *
136
+ * One function rather than the same three-way choice written out in both
137
+ * `systemAppendFor` texts — and it has a second caller that made it worth
138
+ * extracting: the Codex adapter asks it whether a MODE SWITCH would change what
139
+ * the agent was told, because Codex's developer instructions are fixed when the
140
+ * thread opens and cannot be edited afterwards.
141
+ *
142
+ * The three cases, in order:
143
+ * - the project allows work outside the folder → say where the folder is and
144
+ * what leaving it costs, in EVERY mode (the permission does not depend on
145
+ * the mode, so neither does the sentence);
146
+ * - «Unrestricted» → `null`, nothing at all. Layer 1 is never consulted there,
147
+ * so this line would be the only thing enforcing the rule: the agent refuses
148
+ * itself by a sentence nothing backs up, which is the complaint #418 came
149
+ * from;
150
+ * - otherwise → the rule as it has always been, with layer 1 behind it.
151
+ *
152
+ * The mode passed in must be the one the session is actually IN, not the one it
153
+ * was asked for: a `full` refused on a STRICT workspace keeps the line.
154
+ */
155
+ export function folderRuleFor(spec, mode) {
156
+ // `=== true` — silence keeps the agent in, exactly as `policy.ts` resolves the
157
+ // same field.
158
+ if (spec.gitPolicy?.agentAllowOutsideFolder === true)
159
+ return outsideFolderRule(spec.cwd);
160
+ return mode === 'full' ? null : WORKING_DIRECTORY_RULE;
161
+ }
103
162
  /**
104
163
  * The name our MCP server is registered under inside an agent session.
105
164
  *
package/dist/index.js CHANGED
@@ -381,6 +381,18 @@ function runnerCapabilities(apiUrlOverride) {
381
381
  * been handed a worse problem.
382
382
  */
383
383
  agentGitPolicy: true,
384
+ /**
385
+ * #418: «the agent may work outside the project folder» is a setting of the
386
+ * project, and this runner honours it.
387
+ *
388
+ * Its own flag rather than a second meaning for `agentGitPolicy`: the two
389
+ * ship a release apart, and a server on 0.61.x announces the git policy
390
+ * perfectly honestly while ignoring this field completely. Announced so the
391
+ * settings card can say so — warn, not refuse, for the same reason as the
392
+ * git policy: a manager must be able to record the decision before the last
393
+ * machine in the fleet is up to date.
394
+ */
395
+ agentOutsideFolder: true,
384
396
  /**
385
397
  * Session 16: git is git.
386
398
  *
package/dist/journal.d.ts CHANGED
@@ -58,6 +58,23 @@ export declare class SessionJournal {
58
58
  anchor: string;
59
59
  providerSessionId: string;
60
60
  } | null;
61
+ /**
62
+ * Every card this journal has published, and whether it is still open —
63
+ * in the order they were asked (#392).
64
+ *
65
+ * The persistent twin of the supervisor's `running.openQuestions`: that set
66
+ * dies with the process, and a process that dies ungracefully never gets to
67
+ * `shutdown()`, which is the only place it withdraws them. This one is
68
+ * rebuilt from the file, so the next process can close what the last one
69
+ * left open — the card in the browser does not know the runner restarted.
70
+ *
71
+ * Closed cards are remembered too, not only dropped: «this card was closed
72
+ * by somebody» is the fact that stops a SECOND tombstone. A queued answer
73
+ * that reaches the next process after a restore has already closed the card
74
+ * must rescue the words and say nothing more about the card — and the only
75
+ * witness that the card was closed is this file.
76
+ */
77
+ private readonly asks;
61
78
  constructor(sessionId: string, dir?: string);
62
79
  private replay;
63
80
  private write;
@@ -86,6 +103,29 @@ export declare class SessionJournal {
86
103
  recordStatus(status: string, extra?: Record<string, unknown>, epoch?: number): void;
87
104
  /** Assign the next seq and persist the event before it is sent. */
88
105
  append(eventType: string, payload: Record<string, unknown>): JournalEvent;
106
+ /**
107
+ * Keep `openAsks` in step with the cards going out through `append` (#392).
108
+ *
109
+ * Here and not in the supervisor, because `append` is the one door every
110
+ * event takes: a card that was published was published through it, and so
111
+ * was every resolution — the adapter's own, `withdrawOpenQuestions`, the
112
+ * `not_open` miss. A set kept beside the callers would need every one of
113
+ * them to remember it, which is exactly how the in-memory set came to be
114
+ * empty at the moment it was needed.
115
+ *
116
+ * A resolution for a card this journal never published writes nothing:
117
+ * there is nothing to close, and remembering strangers would only grow the
118
+ * file.
119
+ */
120
+ private trackAsk;
121
+ /** The cards still waiting on a person, oldest first (#392). */
122
+ openAskIds(): string[];
123
+ /**
124
+ * What this journal knows about one card (#392): `open`, `closed`, or
125
+ * nothing at all — a card asked by a runner from before these lines existed,
126
+ * or one this state directory never saw.
127
+ */
128
+ askState(askId: string): 'open' | 'closed' | undefined;
89
129
  /**
90
130
  * Never reuse a seq the API already stored: after a runner state-dir wipe the
91
131
  * local counter restarts at 1 and every replayed event would collide with an