@bridge4dev/runner 0.31.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
@@ -874,6 +922,12 @@ class ClaudeSession {
874
922
  ? {}
875
923
  : { agentAutoCommit: this.spec.agentAutoCommit }),
876
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 ?? {}),
877
931
  worktreePath: this.spec.cwd,
878
932
  });
879
933
  if (verdict.decision !== 'allow')
@@ -899,6 +953,17 @@ class ClaudeSession {
899
953
  this.spec.trustMode = policy.trustMode;
900
954
  if (policy.agentAutoCommit !== undefined)
901
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;
902
967
  if (!trustChanged)
903
968
  return;
904
969
  // The manager tightened to STRICT while this session was running with
@@ -1013,6 +1078,18 @@ class ClaudeSession {
1013
1078
  switch (msg.subtype) {
1014
1079
  case 'background_tasks_changed': {
1015
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
+ }
1016
1093
  this.liveTaskIds = [];
1017
1094
  for (const row of rows) {
1018
1095
  const id = typeof row['task_id'] === 'string' ? row['task_id'] : null;
@@ -1067,7 +1144,11 @@ class ClaudeSession {
1067
1144
  }
1068
1145
  case 'task_progress': {
1069
1146
  const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
1070
- 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))
1071
1152
  break;
1072
1153
  this.touchTask(id, {
1073
1154
  ...(typeof msg['description'] === 'string' ? { title: msg['description'] } : {}),
@@ -1097,6 +1178,16 @@ class ClaudeSession {
1097
1178
  const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
1098
1179
  if (!id)
1099
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
+ }
1100
1191
  this.touchTask(id, {
1101
1192
  status: taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed'),
1102
1193
  ...(typeof msg['summary'] === 'string' ? { summary: msg['summary'] } : {}),
@@ -1129,31 +1220,29 @@ class ClaudeSession {
1129
1220
  touchTask(id, patch) {
1130
1221
  const existing = this.tasks.get(id);
1131
1222
  if (existing) {
1132
- const wasRunning = existing.status === 'running';
1223
+ const settledAs = existing.status === 'running' ? null : existing.status;
1133
1224
  Object.assign(existing, patch);
1134
1225
  if (patch.title)
1135
1226
  existing.title = truncate(patch.title, TASK_TITLE_LIMIT);
1136
1227
  if (patch.summary)
1137
1228
  existing.summary = truncate(patch.summary, TASK_TITLE_LIMIT);
1138
- // Counted on the edge out of `running`, so a task that settles twice — a
1139
- // `task_updated` and then its `task_notification`, or a notification
1140
- // redelivered after a reconnect — is still counted once.
1141
- if (wasRunning && existing.status !== 'running')
1142
- 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;
1143
1237
  return;
1144
1238
  }
1145
- this.turnTasksStarted += 1;
1146
- const status = patch.status ?? 'running';
1147
- // A row that arrives already finished (a notification for a task we never
1148
- // saw start) counts as both, or `done` could exceed nothing at all.
1149
- if (status !== 'running')
1150
- this.turnTasksDone += 1;
1151
1239
  this.tasks.set(id, {
1152
1240
  ...patch,
1153
1241
  id,
1154
1242
  kind: patch.kind ?? 'task',
1155
1243
  title: truncate(patch.title ?? 'Working…', TASK_TITLE_LIMIT),
1156
- status,
1244
+ status: patch.status ?? 'running',
1245
+ turnEpoch: this.turnEpoch,
1157
1246
  startedAt: Date.now(),
1158
1247
  // The only free-text field an agent writes with no length of its own:
1159
1248
  // a subagent's closing `summary` runs to kilobytes, and twenty of them
@@ -1201,13 +1290,31 @@ class ClaudeSession {
1201
1290
  // this machine's clock and the browser's is a different one — subtracting
1202
1291
  // across them put the dev server's clock skew straight into the number,
1203
1292
  // so a host ten minutes behind showed «10m 03s» on a task one second old.
1204
- .map((task) => ({ ...task, ageMs: Math.max(0, now - task.startedAt) }));
1205
- const payload = {
1206
- type: 'agent_tasks',
1207
- tasks,
1208
- done: this.turnTasksDone,
1209
- total: this.turnTasksStarted,
1210
- };
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 };
1211
1318
  // A frame that says exactly what the last one said is not worth a row in
1212
1319
  // the session feed (QA-111 M4). `task_progress` fires every ~30s per
1213
1320
  // running subagent and usually carries nothing new, and with twenty of
@@ -1250,9 +1357,13 @@ class ClaudeSession {
1250
1357
  if (!live.has(id))
1251
1358
  this.tasks.delete(id);
1252
1359
  }
1253
- this.skipTaskIds.clear();
1254
- this.turnTasksDone = 0;
1255
- 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;
1256
1367
  // Straight through `flushTasks` rather than an empty frame of its own: the
1257
1368
  // counters changed, so the fingerprint differs and it will publish — and
1258
1369
  // what it publishes is the truth about what is still running.
@@ -1302,6 +1413,10 @@ class ClaudeSession {
1302
1413
  ? {}
1303
1414
  : { agentAutoCommit: this.spec.agentAutoCommit }),
1304
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 ?? {}),
1305
1420
  worktreePath: this.spec.cwd,
1306
1421
  });
1307
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
@@ -670,6 +692,8 @@ class CodexSession {
670
692
  ? {}
671
693
  : { agentAutoCommit: this.spec.agentAutoCommit }),
672
694
  ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
695
+ // Session 18 — spread whole; see the note in the Claude adapter.
696
+ ...(this.spec.gitPolicy ?? {}),
673
697
  worktreePath: this.spec.cwd,
674
698
  });
675
699
  if (verdict.decision !== 'allow')
@@ -790,6 +814,8 @@ class CodexSession {
790
814
  ? {}
791
815
  : { agentAutoCommit: this.spec.agentAutoCommit }),
792
816
  ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
817
+ // Session 18 — spread whole; see the note in the Claude adapter.
818
+ ...(this.spec.gitPolicy ?? {}),
793
819
  worktreePath: this.spec.cwd,
794
820
  });
795
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;
@@ -185,6 +185,14 @@ export interface SessionSpec {
185
185
  * so without this the agent could rewrite its own next system prompt.
186
186
  */
187
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;
188
196
  mode?: AgentMode;
189
197
  model?: string;
190
198
  effort?: string;
@@ -462,6 +470,13 @@ export interface AgentSession {
462
470
  setWorkspacePolicy(policy: {
463
471
  trustMode?: TrustMode;
464
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;
465
480
  }): void;
466
481
  /** Interrupt the current turn (session stays resumable). */
467
482
  interrupt(): Promise<void>;
package/dist/index.js CHANGED
@@ -275,6 +275,19 @@ function runnerCapabilities(apiUrlOverride) {
275
275
  * runner incapable of the setting it honours.
276
276
  */
277
277
  agentAutoCommit: true,
278
+ /**
279
+ * Session 18: the agent's git rules come from the PROJECT, not from this
280
+ * binary.
281
+ *
282
+ * `agentPushBan`, `agentProtectedBranches`, `agentAllowForcePush` and
283
+ * `agentAllowDestructiveGit` are read off the workspace and applied on
284
+ * every tool call. Announced so the settings card can warn that a server
285
+ * without it will go on refusing every push whatever the switch says —
286
+ * warn, not refuse: the fleet updates one machine at a time and a manager
287
+ * who cannot record the intention until the last of them is current has
288
+ * been handed a worse problem.
289
+ */
290
+ agentGitPolicy: true,
278
291
  /**
279
292
  * Session 16: git is git.
280
293
  *
package/dist/policy.d.ts CHANGED
@@ -1,6 +1,47 @@
1
1
  import type { AgentMode } from './adapters/types.js';
2
2
  export type TrustMode = 'STRICT' | 'NORMAL' | 'AUTO';
3
- export interface PolicyContext {
3
+ /**
4
+ * The project's git policy for agents (session 18) — one shape, carried
5
+ * unchanged from the API's descriptor through the supervisor and the adapters
6
+ * into `PolicyContext`.
7
+ *
8
+ * A named interface rather than four fields repeated in five places, because
9
+ * these are the fields whose DIRECTION matters: see the polarity note on
10
+ * `agentPushBan`. A copy that drifts is a copy that eventually gets one of
11
+ * them the wrong way round.
12
+ */
13
+ export interface AgentGitPolicy {
14
+ /**
15
+ * «Принудительно запретить push» — the project's switch.
16
+ *
17
+ * READ AS `!== false`. Not `=== true`, and not truthiness.
18
+ *
19
+ * `undefined` means an API too old to send the field, and there the answer
20
+ * must be what it has always been: refused. This is the OPPOSITE polarity to
21
+ * `agentAutoCommit`, where silence permits — and they sit close together on
22
+ * purpose so that whoever copies one reads why the other differs. Getting it
23
+ * backwards does not fail a test, it silently hands every runner on an old
24
+ * API permission to push.
25
+ */
26
+ agentPushBan?: boolean;
27
+ /**
28
+ * Branches the agent may never push to, whatever `agentPushBan` says.
29
+ *
30
+ * `undefined` means «nobody said», which is `['main', 'master']` — the pair
31
+ * that was hardcoded until this release. An EMPTY ARRAY is a real answer and
32
+ * means «nothing is protected»; the two must not be conflated, which is why
33
+ * nothing here writes `list?.length ? … : DEFAULT`.
34
+ */
35
+ agentProtectedBranches?: string[];
36
+ /**
37
+ * `undefined` and `false` both mean no. Force into a protected branch is
38
+ * refused whatever this says — the two rules are AND-ed, not OR-ed.
39
+ */
40
+ agentAllowForcePush?: boolean;
41
+ /** `git reset --hard` and `git clean`. `undefined` and `false` mean no. */
42
+ agentAllowDestructiveGit?: boolean;
43
+ }
44
+ export interface PolicyContext extends AgentGitPolicy {
4
45
  trustMode: TrustMode;
5
46
  /**
6
47
  * The interaction mode the SESSION is in — the dial in the composer, as
@@ -76,6 +117,16 @@ export declare function isInsideWorktree(p: string, worktreePath: string): boole
76
117
  * next git invocation. Neither is ever ordinary agent work.
77
118
  */
78
119
  export declare function isGitInternalPath(p: string): boolean;
120
+ /**
121
+ * The project's git rules, applied to one Bash command.
122
+ *
123
+ * Returns a refusal, or null when the command has nothing to do with them.
124
+ * Every branch of this function is reachable only when the project has
125
+ * DELIBERATELY switched something on — with the shipped defaults the first
126
+ * check refuses every push and the rest never run, which is byte for byte the
127
+ * behaviour of the four regexes it replaces.
128
+ */
129
+ export declare function evaluateGitPolicy(command: string, ctx: PolicyContext): PolicyDecision | null;
79
130
  export interface RecipeCommandContext {
80
131
  /**
81
132
  * The docker compose project this preview owns, from `preview.project` in the