@bridge4dev/runner 0.30.0 → 0.33.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.
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { AsyncQueue } from '../async-queue.js';
5
5
  import { log } from '../log.js';
6
6
  import { mcpConfigPath } from '../paths.js';
7
- import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
7
+ import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
8
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):
@@ -138,22 +138,49 @@ const MODE_TO_PERMISSION = {
138
138
  auto: 'default',
139
139
  full: 'bypassPermissions',
140
140
  };
141
- const SYSTEM_APPEND = [
142
- 'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
143
- 'Rules:',
144
- '- Work ONLY inside the current working directory (a dedicated git worktree on a session branch).',
145
- // Session 13: pushing is now refused outright by layer 1, in every trust
146
- // mode. Saying so here is not a duplicate of the rule — it saves the agent a
147
- // turn spent discovering the refusal, and tells it what to do instead.
148
- '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked. A human presses «Push» and «Apply» in DevBridge when the branch is ready.',
149
- '- If DevBridge MCP tools (mcp__devbridge__*) are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
150
- '- The user is not in a terminal, but they DO answer: when you need a decision, use the AskUserQuestion tool. It is rendered as a card in the DevBridge dashboard and the call waits — however long it takes — until a human answers it. Only ask in plain text if the tool is unavailable.',
151
- '- Never decide for the user when you asked them a question. If the tool comes back saying the question was withdrawn, stop and wait rather than guessing.',
152
- '- Never print secrets (tokens, API keys, private keys) in your output.',
153
- ].join('\n');
141
+ /**
142
+ * DevBridge's own rules — composed per session since session 18.
143
+ *
144
+ * It was a flat constant, and two of its lines were untrue for most sessions.
145
+ * «a dedicated git worktree on a session branch» is false in `workMode: DIRECT`,
146
+ * which has been the DEFAULT since session 16; and the push sentence is now
147
+ * only true when the project has «Принудительно запретить push» switched on.
148
+ *
149
+ * A system-prompt line that does not match the rule underneath it is worse than
150
+ * no line: the agent plans around a restriction that is not there, or walks
151
+ * into one it was told did not exist.
152
+ */
153
+ function systemAppendFor(spec) {
154
+ const pushBanned = spec.gitPolicy?.agentPushBan !== false;
155
+ const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
156
+ return [
157
+ 'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
158
+ 'Rules:',
159
+ '- Work ONLY inside the current working directory.',
160
+ // Session 13: pushing was refused outright by layer 1 in every trust mode,
161
+ // and saying so here saved the agent a turn spent discovering it. Session
162
+ // 18 makes it the project's decision — so the sentence has to follow the
163
+ // decision, which is the whole of what the owner asked for: switch it off
164
+ // and nothing about push is added here at all.
165
+ pushBanned
166
+ ? '- 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.'
167
+ : guarded.length > 0
168
+ ? // Not a restriction invented here: it is the rule layer 1 will apply,
169
+ // stated in advance. The branch names come from the project's own
170
+ // settings and are already validated as git refs by the API and the
171
+ // protocol schema, so nothing user-typed reaches this text unchecked.
172
+ `- Commit your work in the current branch with clear messages. You may push, except to these protected branches: ${guarded.join(', ')}. Name the branch explicitly — \`git push <remote> <branch>\`.`
173
+ : '- Commit your work in the current branch with clear messages.',
174
+ '- If DevBridge MCP tools (mcp__devbridge__*) are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
175
+ '- The user is not in a terminal, but they DO answer: when you need a decision, use the AskUserQuestion tool. It is rendered as a card in the DevBridge dashboard and the call waits — however long it takes — until a human answers it. Only ask in plain text if the tool is unavailable.',
176
+ '- Never decide for the user when you asked them a question. If the tool comes back saying the question was withdrawn, stop and wait rather than guessing.',
177
+ '- Never print secrets (tokens, API keys, private keys) in your output.',
178
+ ].join('\n');
179
+ }
154
180
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
155
- function composeSystemAppend(workspaceContext) {
156
- return workspaceContext ? `${SYSTEM_APPEND}\n\n${workspaceContext}` : SYSTEM_APPEND;
181
+ function composeSystemAppend(spec) {
182
+ const base = systemAppendFor(spec);
183
+ return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
157
184
  }
158
185
  /**
159
186
  * The five levels the Agent SDK declares, and the one gate that keeps a
@@ -210,12 +237,24 @@ const TASK_LIST_CAP = 20;
210
237
  * `DevSessionEvent`, so the tray coalesces rather than narrating.
211
238
  */
212
239
  const TASK_PUBLISH_INTERVAL_MS = 1_500;
213
- /** What the agent calls a finished task, mapped onto the tray's three states. */
240
+ /**
241
+ * What the agent calls a finished task, mapped onto the tray's three states.
242
+ *
243
+ * Every way a task can STOP has to be named here, not just the two happy ones.
244
+ * An unlisted outcome fell through to `running`, which used to be merely
245
+ * cosmetic — a cancelled subagent sat in the tray spinning until the live set
246
+ * happened to drop it. Since the turn counters are derived from these statuses
247
+ * (#147) it is no longer cosmetic: a settled row degrading back to `running`
248
+ * would make «7 of 21 done» step backwards to «6 of 21 done» on screen.
249
+ */
214
250
  function taskStatus(raw) {
215
251
  if (raw === 'completed')
216
252
  return 'done';
217
253
  if (raw === 'failed' || raw === 'killed')
218
254
  return 'failed';
255
+ if (raw === 'cancelled' || raw === 'canceled' || raw === 'timed_out' || raw === 'aborted') {
256
+ return 'failed';
257
+ }
219
258
  return 'running';
220
259
  }
221
260
  /**
@@ -311,9 +350,18 @@ class ClaudeSession {
311
350
  tasks = new Map();
312
351
  /** Ids currently in the agent's live set — the tray shows exactly these. */
313
352
  liveTaskIds = [];
314
- /** Started / finished within the CURRENT turn: the «14 of 20» numerator. */
315
- turnTasksStarted = 0;
316
- turnTasksDone = 0;
353
+ /**
354
+ * Which turn is being counted (#147).
355
+ *
356
+ * There are no `started` / `done` accumulators any more, and that is the
357
+ * whole fix. Two counters with different lifetimes could disagree, and did:
358
+ * `endTaskTurn` zeroed both while deliberately keeping the ROWS of tasks
359
+ * still running, so a background shell that outlived its turn was counted as
360
+ * finished in the next one without ever having been counted as started —
361
+ * «22 of 21 done». Both numbers are now derived from this map in one pass, so
362
+ * `done <= total` holds by cardinality rather than by clamping.
363
+ */
364
+ turnEpoch = 0;
317
365
  taskPublishTimer = null;
318
366
  taskPublishedAt = 0;
319
367
  /** Last published snapshot, minus the ages — see `flushTasks` (QA-111 M4). */
@@ -436,7 +484,7 @@ class ClaudeSession {
436
484
  systemPrompt: {
437
485
  type: 'preset',
438
486
  preset: 'claude_code',
439
- append: composeSystemAppend(spec.workspaceContext),
487
+ append: composeSystemAppend(spec),
440
488
  },
441
489
  canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
442
490
  // Ticket #113: a running subagent forks its own conversation every ~30s
@@ -873,6 +921,13 @@ class ClaudeSession {
873
921
  ...(this.spec.agentAutoCommit === undefined
874
922
  ? {}
875
923
  : { agentAutoCommit: this.spec.agentAutoCommit }),
924
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
925
+ // The fourth entry point into `evaluateToolUse`, and it was the one
926
+ // that did not carry the git policy (QA-134 MINOR-1). Safe direction —
927
+ // without the fields everything resolves to «refused» and this function
928
+ // only ever releases — but a project that allows `git clean` would have
929
+ // left a parked card unreleased in Claude while Codex released it.
930
+ ...(this.spec.gitPolicy ?? {}),
876
931
  worktreePath: this.spec.cwd,
877
932
  });
878
933
  if (verdict.decision !== 'allow')
@@ -898,6 +953,17 @@ class ClaudeSession {
898
953
  this.spec.trustMode = policy.trustMode;
899
954
  if (policy.agentAutoCommit !== undefined)
900
955
  this.spec.agentAutoCommit = policy.agentAutoCommit;
956
+ // Replaced whole (session 18). The four fields are one decision and the API
957
+ // always sends them together; merging them one by one is how «push is
958
+ // allowed now» could arrive while a stale protected list stayed behind.
959
+ //
960
+ // Only the RULES change live. The system-prompt sentence composed at launch
961
+ // stays as it was until the session is restarted — telling an agent
962
+ // mid-turn that a rule it was given no longer applies is a bigger surprise
963
+ // than letting it discover the refusal is gone, and the rule is what
964
+ // actually enforces anything.
965
+ if (policy.gitPolicy !== undefined)
966
+ this.spec.gitPolicy = policy.gitPolicy;
901
967
  if (!trustChanged)
902
968
  return;
903
969
  // The manager tightened to STRICT while this session was running with
@@ -1012,6 +1078,18 @@ class ClaudeSession {
1012
1078
  switch (msg.subtype) {
1013
1079
  case 'background_tasks_changed': {
1014
1080
  const rows = Array.isArray(msg['tasks']) ? msg['tasks'] : [];
1081
+ // The level signal is the authority on what EXISTS, so it is also the
1082
+ // right place to forget an ambient id (#147, QA-131 m3). Without this
1083
+ // the set only ever shrank on a settling `task_notification`, and a
1084
+ // housekeeping task that is killed, or that reports a status this
1085
+ // runner does not recognise, left its id remembered for the life of the
1086
+ // session — where a reused id would be invisible forever, in the tray
1087
+ // and in the counters, with nothing on screen to explain why.
1088
+ const announced = new Set(rows.map((row) => (typeof row['task_id'] === 'string' ? row['task_id'] : '')));
1089
+ for (const id of [...this.skipTaskIds]) {
1090
+ if (!announced.has(id))
1091
+ this.skipTaskIds.delete(id);
1092
+ }
1015
1093
  this.liveTaskIds = [];
1016
1094
  for (const row of rows) {
1017
1095
  const id = typeof row['task_id'] === 'string' ? row['task_id'] : null;
@@ -1066,7 +1144,11 @@ class ClaudeSession {
1066
1144
  }
1067
1145
  case 'task_progress': {
1068
1146
  const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
1069
- if (!id)
1147
+ // Housekeeping stays hidden here too. `task_started` deletes an ambient
1148
+ // row from the map, and without this guard the next progress frame
1149
+ // simply created it again — as a visible row, and as work in the turn's
1150
+ // denominator (#147).
1151
+ if (!id || this.skipTaskIds.has(id))
1070
1152
  break;
1071
1153
  this.touchTask(id, {
1072
1154
  ...(typeof msg['description'] === 'string' ? { title: msg['description'] } : {}),
@@ -1096,6 +1178,16 @@ class ClaudeSession {
1096
1178
  const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
1097
1179
  if (!id)
1098
1180
  break;
1181
+ if (this.skipTaskIds.has(id)) {
1182
+ // Same guard as `task_progress`, plus the id's retirement: a settled
1183
+ // ambient task can never re-enter the live set, so this is the moment
1184
+ // the set can forget it without letting it back in (#147).
1185
+ if (taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed') !==
1186
+ 'running') {
1187
+ this.skipTaskIds.delete(id);
1188
+ }
1189
+ break;
1190
+ }
1099
1191
  this.touchTask(id, {
1100
1192
  status: taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed'),
1101
1193
  ...(typeof msg['summary'] === 'string' ? { summary: msg['summary'] } : {}),
@@ -1128,31 +1220,29 @@ class ClaudeSession {
1128
1220
  touchTask(id, patch) {
1129
1221
  const existing = this.tasks.get(id);
1130
1222
  if (existing) {
1131
- const wasRunning = existing.status === 'running';
1223
+ const settledAs = existing.status === 'running' ? null : existing.status;
1132
1224
  Object.assign(existing, patch);
1133
1225
  if (patch.title)
1134
1226
  existing.title = truncate(patch.title, TASK_TITLE_LIMIT);
1135
1227
  if (patch.summary)
1136
1228
  existing.summary = truncate(patch.summary, TASK_TITLE_LIMIT);
1137
- // Counted on the edge out of `running`, so a task that settles twice — a
1138
- // `task_updated` and then its `task_notification`, or a notification
1139
- // redelivered after a reconnect — is still counted once.
1140
- if (wasRunning && existing.status !== 'running')
1141
- this.turnTasksDone += 1;
1229
+ // Settling is one-way. A task that has stopped cannot start again under
1230
+ // the same id: the only messages that could say so are a late
1231
+ // `task_updated` carrying a status this runner does not recognise, or a
1232
+ // frame redelivered after a reconnect — neither of which is news that the
1233
+ // work resumed. Letting either through would make the derived `done`
1234
+ // count downwards on screen.
1235
+ if (settledAs && existing.status === 'running')
1236
+ existing.status = settledAs;
1142
1237
  return;
1143
1238
  }
1144
- this.turnTasksStarted += 1;
1145
- const status = patch.status ?? 'running';
1146
- // A row that arrives already finished (a notification for a task we never
1147
- // saw start) counts as both, or `done` could exceed nothing at all.
1148
- if (status !== 'running')
1149
- this.turnTasksDone += 1;
1150
1239
  this.tasks.set(id, {
1151
1240
  ...patch,
1152
1241
  id,
1153
1242
  kind: patch.kind ?? 'task',
1154
1243
  title: truncate(patch.title ?? 'Working…', TASK_TITLE_LIMIT),
1155
- status,
1244
+ status: patch.status ?? 'running',
1245
+ turnEpoch: this.turnEpoch,
1156
1246
  startedAt: Date.now(),
1157
1247
  // The only free-text field an agent writes with no length of its own:
1158
1248
  // a subagent's closing `summary` runs to kilobytes, and twenty of them
@@ -1200,13 +1290,31 @@ class ClaudeSession {
1200
1290
  // this machine's clock and the browser's is a different one — subtracting
1201
1291
  // across them put the dev server's clock skew straight into the number,
1202
1292
  // so a host ten minutes behind showed «10m 03s» on a task one second old.
1203
- .map((task) => ({ ...task, ageMs: Math.max(0, now - task.startedAt) }));
1204
- const payload = {
1205
- type: 'agent_tasks',
1206
- tasks,
1207
- done: this.turnTasksDone,
1208
- total: this.turnTasksStarted,
1209
- };
1293
+ .map(({ turnEpoch: _turnEpoch, ...task }) => ({
1294
+ ...task,
1295
+ ageMs: Math.max(0, now - task.startedAt),
1296
+ }));
1297
+ // Both numbers, one pass, one map (#147). `done` counts a SUBSET of what
1298
+ // `total` counts, so `done <= total` is a property of set cardinality and
1299
+ // cannot be broken by a message ordering, a redelivery, a re-title, an
1300
+ // ambient row being deleted or a turn boundary. There is no pair of
1301
+ // counters to keep in agreement, because there is no pair.
1302
+ //
1303
+ // Tasks that outlived an earlier turn keep that turn's epoch: they still
1304
+ // render as live rows — background work outliving its turn is the whole
1305
+ // point of the tray — but they belong to neither number here, exactly as
1306
+ // this file's own doctrine says («done/total are per-turn by definition.
1307
+ // The live set does NOT»).
1308
+ let total = 0;
1309
+ let done = 0;
1310
+ for (const task of this.tasks.values()) {
1311
+ if (task.turnEpoch !== this.turnEpoch)
1312
+ continue;
1313
+ total += 1;
1314
+ if (task.status !== 'running')
1315
+ done += 1;
1316
+ }
1317
+ const payload = { type: 'agent_tasks', tasks, done, total };
1210
1318
  // A frame that says exactly what the last one said is not worth a row in
1211
1319
  // the session feed (QA-111 M4). `task_progress` fires every ~30s per
1212
1320
  // running subagent and usually carries nothing new, and with twenty of
@@ -1249,9 +1357,13 @@ class ClaudeSession {
1249
1357
  if (!live.has(id))
1250
1358
  this.tasks.delete(id);
1251
1359
  }
1252
- this.skipTaskIds.clear();
1253
- this.turnTasksDone = 0;
1254
- this.turnTasksStarted = 0;
1360
+ // `skipTaskIds` is NOT cleared here any more (#147). An ambient task the
1361
+ // SDK asked consumers to hide can span a turn boundary, and clearing the
1362
+ // set let its id back into the live list on the next
1363
+ // `background_tasks_changed` — where it was drawn as a tray row and counted
1364
+ // as work. The id is forgotten when that task settles instead, which is the
1365
+ // moment it stops being able to come back.
1366
+ this.turnEpoch += 1;
1255
1367
  // Straight through `flushTasks` rather than an empty frame of its own: the
1256
1368
  // counters changed, so the fingerprint differs and it will publish — and
1257
1369
  // what it publishes is the truth about what is still running.
@@ -1300,6 +1412,11 @@ class ClaudeSession {
1300
1412
  ...(this.spec.agentAutoCommit === undefined
1301
1413
  ? {}
1302
1414
  : { agentAutoCommit: this.spec.agentAutoCommit }),
1415
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
1416
+ // Session 18. Spread WHOLE rather than field by field: `resolveGitPolicy`
1417
+ // gives every absent field its safe reading, and an object assembled here
1418
+ // with three of the four would be a fourth place to get a polarity wrong.
1419
+ ...(this.spec.gitPolicy ?? {}),
1303
1420
  worktreePath: this.spec.cwd,
1304
1421
  });
1305
1422
  if (verdict.decision === 'allow') {
@@ -1,6 +1,6 @@
1
1
  import { AsyncQueue } from '../async-queue.js';
2
2
  import { log } from '../log.js';
3
- import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
3
+ import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
4
4
  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';
@@ -59,18 +59,36 @@ const MODE_POLICY = {
59
59
  // exactly like Claude's bypassPermissions. The dashboard says so.
60
60
  full: { approvalPolicy: 'never', sandbox: 'danger-full-access', plan: false },
61
61
  };
62
- const SYSTEM_APPEND = [
63
- 'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
64
- 'Rules:',
65
- '- Work ONLY inside the current working directory (a dedicated git worktree on a session branch).',
66
- '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked. A human presses «Push» and «Apply» in DevBridge when the branch is ready.',
67
- '- If DevBridge MCP tools are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
68
- '- The user is not in a terminal: if you need a decision, use your question tool or ask in plain text and end your turn.',
69
- '- Never print secrets (tokens, API keys, private keys) in your output.',
70
- ].join('\n');
62
+ /**
63
+ * DevBridge's own rules — composed per session since session 18, and kept
64
+ * deliberately in step with `systemAppendFor` in the Claude adapter.
65
+ *
66
+ * The two texts were byte-identical on the push line and both were wrong in the
67
+ * same two ways: «a dedicated git worktree on a session branch» is false in
68
+ * `workMode: DIRECT` (the default since session 16), and the push sentence is
69
+ * only true when the project has «Принудительно запретить push» switched on.
70
+ */
71
+ function systemAppendFor(spec) {
72
+ const pushBanned = spec.gitPolicy?.agentPushBan !== false;
73
+ const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
74
+ return [
75
+ 'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
76
+ 'Rules:',
77
+ '- Work ONLY inside the current working directory.',
78
+ pushBanned
79
+ ? '- 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.'
80
+ : guarded.length > 0
81
+ ? `- Commit your work in the current branch with clear messages. You may push, except to these protected branches: ${guarded.join(', ')}. Name the branch explicitly — \`git push <remote> <branch>\`.`
82
+ : '- Commit your work in the current branch with clear messages.',
83
+ '- If DevBridge MCP tools are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
84
+ '- The user is not in a terminal: if you need a decision, use your question tool or ask in plain text and end your turn.',
85
+ '- Never print secrets (tokens, API keys, private keys) in your output.',
86
+ ].join('\n');
87
+ }
71
88
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
72
- function composeSystemAppend(workspaceContext) {
73
- return workspaceContext ? `${SYSTEM_APPEND}\n\n${workspaceContext}` : SYSTEM_APPEND;
89
+ function composeSystemAppend(spec) {
90
+ const base = systemAppendFor(spec);
91
+ return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
74
92
  }
75
93
  // Allowlist, not denylist: whatever secrets live in the daemon's environment
76
94
  // must not reach the agent process. OPENAI_API_KEY is absent by design — it
@@ -411,7 +429,7 @@ class CodexSession {
411
429
  cwd: this.spec.cwd,
412
430
  approvalPolicy: policy.approvalPolicy,
413
431
  sandbox: policy.sandbox,
414
- developerInstructions: composeSystemAppend(this.spec.workspaceContext),
432
+ developerInstructions: composeSystemAppend(this.spec),
415
433
  ...(this.model ? { model: this.model } : {}),
416
434
  ...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
417
435
  };
@@ -479,7 +497,7 @@ class CodexSession {
479
497
  settings: {
480
498
  model: this.model ?? this.threadModel ?? 'gpt-5.5',
481
499
  reasoning_effort: null,
482
- developer_instructions: composeSystemAppend(this.spec.workspaceContext),
500
+ developer_instructions: composeSystemAppend(this.spec),
483
501
  },
484
502
  };
485
503
  this.lastCollabMode = wantCollab;
@@ -607,6 +625,10 @@ class CodexSession {
607
625
  this.spec.trustMode = policy.trustMode;
608
626
  if (policy.agentAutoCommit !== undefined)
609
627
  this.spec.agentAutoCommit = policy.agentAutoCommit;
628
+ // Replaced whole — see the note in the Claude adapter. Only the RULES move
629
+ // live; the system-prompt sentence composed at launch stays until restart.
630
+ if (policy.gitPolicy !== undefined)
631
+ this.spec.gitPolicy = policy.gitPolicy;
610
632
  if (!trustChanged)
611
633
  return;
612
634
  // A manager tightened the project out from under a session that is running
@@ -669,6 +691,9 @@ class CodexSession {
669
691
  ...(this.spec.agentAutoCommit === undefined
670
692
  ? {}
671
693
  : { agentAutoCommit: this.spec.agentAutoCommit }),
694
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
695
+ // Session 18 — spread whole; see the note in the Claude adapter.
696
+ ...(this.spec.gitPolicy ?? {}),
672
697
  worktreePath: this.spec.cwd,
673
698
  });
674
699
  if (verdict.decision !== 'allow')
@@ -788,6 +813,9 @@ class CodexSession {
788
813
  ...(this.spec.agentAutoCommit === undefined
789
814
  ? {}
790
815
  : { agentAutoCommit: this.spec.agentAutoCommit }),
816
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
817
+ // Session 18 — spread whole; see the note in the Claude adapter.
818
+ ...(this.spec.gitPolicy ?? {}),
791
819
  worktreePath: this.spec.cwd,
792
820
  });
793
821
  if (verdict.decision === 'allow') {
@@ -1,4 +1,4 @@
1
- import type { TrustMode } from '../policy.js';
1
+ import type { AgentGitPolicy, TrustMode } from '../policy.js';
2
2
  export interface McpConfig {
3
3
  url: string;
4
4
  token: string;
@@ -176,6 +176,23 @@ export interface SessionSpec {
176
176
  * the same question — may this Bash call go through.
177
177
  */
178
178
  agentAutoCommit?: boolean;
179
+ /**
180
+ * Absolute path of the project's prompt file, set only when it was actually
181
+ * read into `workspaceContext` for this process (session 17).
182
+ *
183
+ * Rides down to `PolicyContext` so layer 1 can refuse writes to it: in
184
+ * `workMode: DIRECT` the project folder is the agent's own working directory,
185
+ * so without this the agent could rewrite its own next system prompt.
186
+ */
187
+ agentPromptFile?: string;
188
+ /**
189
+ * Session 18: the project's git policy. Rides with `trustMode` and
190
+ * `agentAutoCommit` for the same reason — it lands in the same
191
+ * `PolicyContext` and answers the same question, may this Bash call go
192
+ * through. It also decides one sentence of the system prompt, which is the
193
+ * part `agentAutoCommit` does not do.
194
+ */
195
+ gitPolicy?: AgentGitPolicy;
179
196
  mode?: AgentMode;
180
197
  model?: string;
181
198
  effort?: string;
@@ -453,6 +470,13 @@ export interface AgentSession {
453
470
  setWorkspacePolicy(policy: {
454
471
  trustMode?: TrustMode;
455
472
  agentAutoCommit?: boolean;
473
+ /**
474
+ * Session 18. Replaced WHOLE, never merged field by field: the API sends
475
+ * all four together precisely so that a change to one cannot leave the
476
+ * runner holding a stale value of another — «push is allowed now» must not
477
+ * arrive without the list of branches it is still not allowed into.
478
+ */
479
+ gitPolicy?: AgentGitPolicy;
456
480
  }): void;
457
481
  /** Interrupt the current turn (session stays resumable). */
458
482
  interrupt(): Promise<void>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The project's own prompt file — the one thing on this machine that a project
3
+ * can put into an agent's SYSTEM prompt.
4
+ *
5
+ * ## Why a system prompt and not a file the agent reads
6
+ *
7
+ * Measured on a live request (2026-08-05), not assumed. `CLAUDE.md`, `AGENTS.md`
8
+ * and `SessionStart` hook output all arrive inside the first USER message —
9
+ * Claude Code's own prompt says so in as many words: «Treat feedback from hooks
10
+ * … as coming from the user». `systemPrompt.append` arrives in `system[]`,
11
+ * after the CLI's own text and last. Only the second survives a compaction, and
12
+ * only the second can stand next to a rule the CLI itself states.
13
+ *
14
+ * That difference is the whole feature. An Opus-5 system prompt carries
15
+ * «Do not call the AgentTool unless the user requested it»; a project whose
16
+ * process REQUIRES an independent review round has to be able to say so at the
17
+ * same level, or it is simply outranked and nobody can see why.
18
+ *
19
+ * ## Why this file is paranoid
20
+ *
21
+ * Its contents become system-prompt text verbatim, and the path comes off the
22
+ * wire. So every rule is re-derived here rather than trusted from the API: the
23
+ * runner is the process that opens the file, and it is the only side that can
24
+ * see what the path actually resolves to on this disk.
25
+ */
26
+ /** Big enough for a real process document; small enough to stay a prompt. */
27
+ export declare const AGENT_PROMPT_MAX_BYTES: number;
28
+ export type AgentPromptResult = {
29
+ ok: true;
30
+ text: string;
31
+ relPath: string;
32
+ absPath: string;
33
+ bytes: number;
34
+ sha: string;
35
+ }
36
+ /** Already phrased for a human and safe to show — no raw paths beyond the one they typed. */
37
+ | {
38
+ ok: false;
39
+ reason: string;
40
+ };
41
+ /**
42
+ * Read the project's prompt file, or explain why it cannot be read.
43
+ *
44
+ * @param projectRoot Absolute path of the PROJECT FOLDER (`workspace.path`) —
45
+ * not the session worktree. Deliberate, for two reasons that always hold: the
46
+ * file may be uncommitted (a worktree cut from the base branch would not have
47
+ * it), and every session of the project then reads the same rules whatever
48
+ * branch it is on.
49
+ *
50
+ * A third reason used to be written here and was wrong, so it is worth saying
51
+ * plainly (QA-130 MAJOR-3): this does NOT stop a session from rewriting the
52
+ * file that becomes its own next system prompt. It stops it in `BRANCH` mode,
53
+ * where writes outside the worktree are refused — but in `workMode: DIRECT`,
54
+ * which is the default, the project folder IS the session's working
55
+ * directory. What guards it there is the layer-1 rule in `policy.ts`
56
+ * (`agentPromptFile`), and that rule is a guard rather than a guarantee: it
57
+ * covers the file-writing tools, not a shell redirect, and `full` mode
58
+ * bypasses layer 1 altogether by design. The honest backstop is the `sha` in
59
+ * the session feed, which changes when the file does.
60
+ * @param relPath The configured path, relative to `projectRoot`.
61
+ */
62
+ export declare function readAgentPrompt(projectRoot: string, relPath: string): AgentPromptResult;
63
+ /** How the prompt is announced in the session feed and in the journal. */
64
+ export declare function agentPromptSizeLabel(bytes: number): string;
65
+ /**
66
+ * A configured path, safe to put in a line a person reads.
67
+ *
68
+ * The session feed renders a notice as plain text, so backticks would be shown
69
+ * literally rather than as code — and this string can be a REFUSED path, which
70
+ * means it never passed any of the checks above and is only as clean as the API
71
+ * schema made it. Quoted with guillemets, stripped of anything that could break
72
+ * a line, and bounded (QA-130 NIT-13).
73
+ */
74
+ export declare function quotePath(value: string): string;
75
+ //# sourceMappingURL=agent-prompt.d.ts.map