@bridge4dev/runner 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import { AsyncQueue } from '../async-queue.js';
5
5
  import { log } from '../log.js';
6
6
  import { mcpConfigPath } from '../paths.js';
7
7
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
8
- import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
8
+ import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
9
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
10
10
  import { applyUsagePercentages, parseUsageText, probeUsageText } from './claude-usage.js';
11
11
  import { claudeExecutableOption, sessionClaudePath } from '../agent-binary.js';
@@ -180,6 +180,9 @@ function systemAppendFor(spec) {
180
180
  '- 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.',
181
181
  '- 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.',
182
182
  '- Never print secrets (tokens, API keys, private keys) in your output.',
183
+ // #361 п. 5 — only where the folder is shared. Layer 1 asks about these
184
+ // commands anyway; this is so the agent learns the rule before a card.
185
+ ...(spec.workMode === 'DIRECT' ? [DIRECT_BRANCH_RULE] : []),
183
186
  ].join('\n');
184
187
  }
185
188
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
@@ -1207,21 +1210,10 @@ class ClaudeSession {
1207
1210
  for (const [requestId, pending] of [...this.pending]) {
1208
1211
  if (!pending.fromPolicy)
1209
1212
  continue;
1210
- const verdict = evaluateToolUse(pending.toolName, pending.input, {
1211
- trustMode: this.spec.trustMode,
1212
- mode: this.mode,
1213
- ...(this.spec.agentAutoCommit === undefined
1214
- ? {}
1215
- : { agentAutoCommit: this.spec.agentAutoCommit }),
1216
- ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
1217
- // The fourth entry point into `evaluateToolUse`, and it was the one
1218
- // that did not carry the git policy (QA-134 MINOR-1). Safe direction —
1219
- // without the fields everything resolves to «refused» and this function
1220
- // only ever releases — but a project that allows `git clean` would have
1221
- // left a parked card unreleased in Claude while Codex released it.
1222
- ...(this.spec.gitPolicy ?? {}),
1223
- worktreePath: this.spec.cwd,
1224
- });
1213
+ // The fourth entry point into `evaluateToolUse`, and the one that was
1214
+ // once assembled by hand without the git policy (QA-134 MINOR-1). All
1215
+ // four read the same builder now, so a field can only be forgotten once.
1216
+ const verdict = evaluateToolUse(pending.toolName, pending.input, policyContextFor(this.spec, this.mode));
1225
1217
  if (verdict.decision !== 'allow')
1226
1218
  continue;
1227
1219
  this.emit({
@@ -1737,21 +1729,7 @@ class ClaudeSession {
1737
1729
  });
1738
1730
  return this.waitForAnswer(opts, toolName);
1739
1731
  }
1740
- const verdict = evaluateToolUse(toolName, input, {
1741
- trustMode: this.spec.trustMode,
1742
- // Ticket #156: the missing argument. Everything else in this object was
1743
- // already here; the session's own mode was not, so «Auto» decided nothing.
1744
- mode: this.mode,
1745
- ...(this.spec.agentAutoCommit === undefined
1746
- ? {}
1747
- : { agentAutoCommit: this.spec.agentAutoCommit }),
1748
- ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
1749
- // Session 18. Spread WHOLE rather than field by field: `resolveGitPolicy`
1750
- // gives every absent field its safe reading, and an object assembled here
1751
- // with three of the four would be a fourth place to get a polarity wrong.
1752
- ...(this.spec.gitPolicy ?? {}),
1753
- worktreePath: this.spec.cwd,
1754
- });
1732
+ const verdict = evaluateToolUse(toolName, input, policyContextFor(this.spec, this.mode));
1755
1733
  if (verdict.decision === 'allow') {
1756
1734
  return { behavior: 'allow', updatedInput: input };
1757
1735
  }
@@ -1771,7 +1749,12 @@ class ClaudeSession {
1771
1749
  requestId: opts.requestId,
1772
1750
  toolName,
1773
1751
  title: opts.title ?? `Allow ${toolName}?`,
1774
- ...(opts.description ? { description: opts.description } : {}),
1752
+ // The policy's own sentence first: it is the reason this card exists, and
1753
+ // without it the person is asked to approve a command with no hint of
1754
+ // what the runner objected to (#361 п. 5).
1755
+ ...(cardDescription(verdict, opts.description)
1756
+ ? { description: cardDescription(verdict, opts.description) }
1757
+ : {}),
1775
1758
  input: truncateInput(input),
1776
1759
  });
1777
1760
  return this.waitForAnswer(opts, toolName, input);
@@ -5,7 +5,7 @@ import { RUNNER_VERSION } from '../version.js';
5
5
  import { repairCodexAuth } from './codex-home.js';
6
6
  import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
7
7
  import { truncate } from './claude.js';
8
- import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
8
+ import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
9
  import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
10
10
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
11
11
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
@@ -83,6 +83,9 @@ function systemAppendFor(spec) {
83
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
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
85
  '- Never print secrets (tokens, API keys, private keys) in your output.',
86
+ // #361 п. 5 — only where the folder is shared. Layer 1 asks about these
87
+ // commands anyway; this is so the agent learns the rule before a card.
88
+ ...(spec.workMode === 'DIRECT' ? [DIRECT_BRANCH_RULE] : []),
86
89
  ].join('\n');
87
90
  }
88
91
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
@@ -805,17 +808,7 @@ class CodexSession {
805
808
  for (const [requestId, pending] of [...this.approvals]) {
806
809
  if (!pending.policy)
807
810
  continue;
808
- const verdict = evaluateToolUse(pending.policy.tool, pending.policy.input, {
809
- trustMode: this.spec.trustMode,
810
- mode: this.mode,
811
- ...(this.spec.agentAutoCommit === undefined
812
- ? {}
813
- : { agentAutoCommit: this.spec.agentAutoCommit }),
814
- ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
815
- // Session 18 — spread whole; see the note in the Claude adapter.
816
- ...(this.spec.gitPolicy ?? {}),
817
- worktreePath: this.spec.cwd,
818
- });
811
+ const verdict = evaluateToolUse(pending.policy.tool, pending.policy.input, policyContextFor(this.spec, this.mode));
819
812
  if (verdict.decision !== 'allow')
820
813
  continue;
821
814
  this.approvals.delete(requestId);
@@ -930,19 +923,7 @@ class CodexSession {
930
923
  const enriched = this.describeApproval(request);
931
924
  const verdict = enriched.forceAsk
932
925
  ? { decision: 'ask', reason: 'details unavailable' }
933
- : evaluateToolUse(enriched.policyTool, enriched.policyInput, {
934
- trustMode: this.spec.trustMode,
935
- // Ticket #156, the same missing argument as in the Claude adapter —
936
- // both bridges call one policy, so both have to hand it the mode.
937
- mode: this.mode,
938
- ...(this.spec.agentAutoCommit === undefined
939
- ? {}
940
- : { agentAutoCommit: this.spec.agentAutoCommit }),
941
- ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
942
- // Session 18 — spread whole; see the note in the Claude adapter.
943
- ...(this.spec.gitPolicy ?? {}),
944
- worktreePath: this.spec.cwd,
945
- });
926
+ : evaluateToolUse(enriched.policyTool, enriched.policyInput, policyContextFor(this.spec, this.mode));
946
927
  if (verdict.decision === 'allow') {
947
928
  this.client.respond(request.id, { decision: 'accept' });
948
929
  return;
@@ -968,7 +949,10 @@ class CodexSession {
968
949
  requestId,
969
950
  toolName: enriched.toolName,
970
951
  title: enriched.title,
971
- ...(enriched.description ? { description: enriched.description } : {}),
952
+ // See the note in the Claude adapter — one policy, two bridges, one card.
953
+ ...(cardDescription(verdict, enriched.description)
954
+ ? { description: cardDescription(verdict, enriched.description) }
955
+ : {}),
972
956
  input: enriched.input,
973
957
  });
974
958
  }
@@ -1,4 +1,4 @@
1
- import type { AgentGitPolicy, TrustMode } from '../policy.js';
1
+ import type { AgentGitPolicy, PolicyContext, PolicyDecision, TrustMode } from '../policy.js';
2
2
  export interface McpConfig {
3
3
  url: string;
4
4
  token: string;
@@ -256,6 +256,16 @@ export interface SessionSpec {
256
256
  * part `agentAutoCommit` does not do.
257
257
  */
258
258
  gitPolicy?: AgentGitPolicy;
259
+ /**
260
+ * Does this session work in the project folder itself (`DIRECT`) or in a
261
+ * worktree of its own (`BRANCH`)? Same reason to be here as
262
+ * `agentPromptFile`: it lands in `PolicyContext` and answers the same
263
+ * question — may this Bash call go through (#361 п. 5, ADR 0004).
264
+ *
265
+ * It also decides one line of the system prompt, which `agentPromptFile`
266
+ * does not do.
267
+ */
268
+ workMode?: 'DIRECT' | 'BRANCH';
259
269
  mode?: AgentMode;
260
270
  model?: string;
261
271
  effort?: string;
@@ -278,6 +288,40 @@ export interface SessionSpec {
278
288
  mcp?: McpConfig;
279
289
  maxBudgetUsd?: number;
280
290
  }
291
+ /**
292
+ * The one place a `SessionSpec` becomes a `PolicyContext`.
293
+ *
294
+ * There are four entry points into `evaluateToolUse` — two per adapter — and
295
+ * every one of them used to assemble this object by hand. QA-134 MINOR-1 is
296
+ * what that costs: one of the four had been written before the git policy
297
+ * existed and never learned about it, so a project that allowed `git clean`
298
+ * left a parked card unreleased in Claude while Codex released it. The next
299
+ * field to be added would have had four chances to be forgotten; now it has
300
+ * one place to be added.
301
+ *
302
+ * Optional fields are spread rather than set to `undefined`, because
303
+ * `resolveGitPolicy` and its neighbours read «absent» as a decision.
304
+ */
305
+ export declare function policyContextFor(spec: SessionSpec, mode: AgentMode | undefined): PolicyContext;
306
+ /**
307
+ * What the permission card should say under its title.
308
+ *
309
+ * The policy's explanation first, the tool's own description after it: the
310
+ * person is answering «may this run», and «why are you asking me» is the half
311
+ * that was missing (#361 п. 5). Returns undefined when there is nothing to add.
312
+ */
313
+ export declare function cardDescription(verdict: PolicyDecision, own: string | undefined): string | undefined;
314
+ /**
315
+ * The one sentence of the system prompt that only DIRECT sessions get
316
+ * (#361 п. 5, ADR 0004).
317
+ *
318
+ * A constant rather than a line in each adapter, because the two
319
+ * `systemAppendFor` texts are already two hand-synced copies of each other and
320
+ * a third divergence was not worth having. Layer 1 asks about these commands
321
+ * anyway — this exists so the agent does not have to learn the rule by walking
322
+ * into a card.
323
+ */
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>`.";
281
325
  /**
282
326
  * One question inside an agent's question call (session 12).
283
327
  *
@@ -33,6 +33,59 @@ export const MODE_WITHDRAWN_TEXT = 'This project was just set to Strict trust, s
33
33
  export function availableModes(trustMode) {
34
34
  return trustMode === 'STRICT' ? AGENT_MODES.filter((mode) => mode !== 'full') : [...AGENT_MODES];
35
35
  }
36
+ /**
37
+ * The one place a `SessionSpec` becomes a `PolicyContext`.
38
+ *
39
+ * There are four entry points into `evaluateToolUse` — two per adapter — and
40
+ * every one of them used to assemble this object by hand. QA-134 MINOR-1 is
41
+ * what that costs: one of the four had been written before the git policy
42
+ * existed and never learned about it, so a project that allowed `git clean`
43
+ * left a parked card unreleased in Claude while Codex released it. The next
44
+ * field to be added would have had four chances to be forgotten; now it has
45
+ * one place to be added.
46
+ *
47
+ * Optional fields are spread rather than set to `undefined`, because
48
+ * `resolveGitPolicy` and its neighbours read «absent» as a decision.
49
+ */
50
+ export function policyContextFor(spec, mode) {
51
+ return {
52
+ trustMode: spec.trustMode,
53
+ // Ticket #156: the session's own mode. For four sessions it was missing
54
+ // here, so «Auto» moved a value that the function deciding whether to ask
55
+ // had never heard of.
56
+ ...(mode ? { mode } : {}),
57
+ ...(spec.agentAutoCommit === undefined ? {} : { agentAutoCommit: spec.agentAutoCommit }),
58
+ ...(spec.agentPromptFile ? { agentPromptFile: spec.agentPromptFile } : {}),
59
+ ...(spec.workMode ? { workMode: spec.workMode } : {}),
60
+ // Session 18. Spread WHOLE rather than field by field: `resolveGitPolicy`
61
+ // gives every absent field its safe reading, and an object assembled here
62
+ // with three of the four would be a fourth place to get a polarity wrong.
63
+ ...(spec.gitPolicy ?? {}),
64
+ worktreePath: spec.cwd,
65
+ };
66
+ }
67
+ /**
68
+ * What the permission card should say under its title.
69
+ *
70
+ * The policy's explanation first, the tool's own description after it: the
71
+ * person is answering «may this run», and «why are you asking me» is the half
72
+ * that was missing (#361 п. 5). Returns undefined when there is nothing to add.
73
+ */
74
+ export function cardDescription(verdict, own) {
75
+ const parts = [verdict.explain, own].filter((part) => Boolean(part));
76
+ return parts.length > 0 ? parts.join('\n\n') : undefined;
77
+ }
78
+ /**
79
+ * The one sentence of the system prompt that only DIRECT sessions get
80
+ * (#361 п. 5, ADR 0004).
81
+ *
82
+ * A constant rather than a line in each adapter, because the two
83
+ * `systemAppendFor` texts are already two hand-synced copies of each other and
84
+ * a third divergence was not worth having. Layer 1 asks about these commands
85
+ * anyway — this exists so the agent does not have to learn the rule by walking
86
+ * into a card.
87
+ */
88
+ 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>`.';
36
89
  /**
37
90
  * The name our MCP server is registered under inside an agent session.
38
91
  *
@@ -44,6 +44,21 @@ export interface CheckpointRecord {
44
44
  agentSession?: string;
45
45
  /** Feed seq of the user message this point sits in front of. */
46
46
  messageSeq?: number;
47
+ /**
48
+ * Other sessions that were mid-turn in this FOLDER while the snapshot was
49
+ * taken (#310).
50
+ *
51
+ * A DIRECT session shares its working tree with every other session on the
52
+ * same project, so a point taken while a neighbour was writing files holds
53
+ * half of that neighbour's work — rewinding files to it would throw away
54
+ * whatever they did after the shutter and before the shutter closed. The
55
+ * conversation is still safe to rewind, and that is the whole distinction.
56
+ *
57
+ * Absent means «the folder was quiet», which is also what every record
58
+ * written before this release means — those were only ever taken in a quiet
59
+ * folder, because a busy one refused to take them at all.
60
+ */
61
+ busySessions?: string[];
47
62
  }
48
63
  export interface CreateCheckpointInput {
49
64
  worktreePath: string;
@@ -53,6 +68,18 @@ export interface CreateCheckpointInput {
53
68
  messageSeq?: number;
54
69
  agentAnchor?: string;
55
70
  agentSession?: string;
71
+ /**
72
+ * Who else is working in this folder — asked as a QUESTION, not handed as an
73
+ * answer (#310).
74
+ *
75
+ * A getter, because the honest reading is «while the snapshot was being
76
+ * taken», and taking it is not instantaneous: `buildIndex` walks the tree and
77
+ * `write-tree` hashes it. Called at both ends and unioned, so a neighbour who
78
+ * started or stopped in between is still counted. It cannot be recorded
79
+ * afterwards either — the metadata is the commit message, and a commit's
80
+ * message cannot be appended to once it exists.
81
+ */
82
+ busySessions?: () => string[];
56
83
  }
57
84
  export type CreateCheckpointResult = {
58
85
  created: true;
@@ -97,6 +124,20 @@ export interface RewindPreview {
97
124
  /** The change is larger than the dialog can honestly list — see `blockedReason`. */
98
125
  truncated?: boolean;
99
126
  totalChanges?: number;
127
+ /**
128
+ * This point was taken while somebody else was working in the folder, so its
129
+ * FILES cannot be trusted — only the conversation can be rewound to it (#310).
130
+ *
131
+ * Deliberately not a `blockedReason`: that field is a closed enum on the API
132
+ * and a new member would be rejected there, and in the dialog it hides the
133
+ * «History only» button — which is the one thing that still works here.
134
+ *
135
+ * The presence of the key carries the warning; the ids are a courtesy that
136
+ * may resolve to no names at all (gotcha 433).
137
+ */
138
+ untrustedFiles?: {
139
+ sessions: string[];
140
+ };
100
141
  }
101
142
  /**
102
143
  * Take a restore point for this worktree.
@@ -106,6 +147,15 @@ export interface RewindPreview {
106
147
  */
107
148
  export declare function createCheckpoint(input: CreateCheckpointInput): Promise<CreateCheckpointResult>;
108
149
  export declare function listCheckpoints(worktreePath: string, sessionId: string): Promise<CheckpointRecord[]>;
150
+ /**
151
+ * How many neighbour ids a restore point carries (#310).
152
+ *
153
+ * The same number as the API's schema and the runner's event: the mark itself
154
+ * is the warning, the names are a courtesy, and a folder with eleven busy
155
+ * sessions is not eleven times more dangerous than one with ten. Gotcha 433 —
156
+ * a cap on a courtesy must degrade, never reject.
157
+ */
158
+ export declare const MAX_BUSY_SESSIONS = 10;
109
159
  /** What a rewind to this checkpoint would do, without doing any of it. */
110
160
  export declare function previewRewind(input: {
111
161
  worktreePath: string;
@@ -152,7 +202,19 @@ export declare function applyRewind(input: {
152
202
  * that is doing nothing wrong. Present ⇒ checked.
153
203
  */
154
204
  expectedTreeOid?: string;
205
+ /**
206
+ * Who else is working in this folder — for the SAFETY point this rewind takes
207
+ * on its way out (#310). Same getter, same reason as in
208
+ * {@link CreateCheckpointInput}.
209
+ */
210
+ busySessions?: () => string[];
155
211
  }): Promise<ApplyRewindResult>;
212
+ /**
213
+ * Said when the files of a restore point cannot be trusted (#310). Its own
214
+ * sentence rather than a `blockedReason`, for the reason written on
215
+ * `RewindPreview.untrustedFiles`.
216
+ */
217
+ export declare const UNTRUSTED_FILES_MESSAGE = "The files at this restore point cannot be trusted: another session was working in this folder when it was taken. Only the conversation can be rewound to it.";
156
218
  export declare function rewindBlockMessage(reason: NonNullable<RewindPreview['blockedReason']>): string;
157
219
  /** Drop every restore point of one session (session deleted or purged). */
158
220
  export declare function dropCheckpoints(worktreePath: string, sessionId: string): Promise<void>;
@@ -362,6 +362,16 @@ function decodeMeta(message) {
362
362
  ? { agentSession: parsed['agentSession'] }
363
363
  : {}),
364
364
  ...(typeof parsed['messageSeq'] === 'number' ? { messageSeq: parsed['messageSeq'] } : {}),
365
+ // A whitelist, so a key absent from THIS list is a key that was written
366
+ // and is never read again. The mark is the whole point of #310, and a
367
+ // silently dropped mark reads exactly like «the folder was quiet».
368
+ ...(Array.isArray(parsed['busySessions'])
369
+ ? {
370
+ busySessions: parsed['busySessions']
371
+ .filter((id) => typeof id === 'string')
372
+ .slice(0, MAX_BUSY_SESSIONS),
373
+ }
374
+ : {}),
365
375
  };
366
376
  }
367
377
  catch {
@@ -379,6 +389,8 @@ export async function createCheckpoint(input) {
379
389
  try {
380
390
  const store = await ensureStore(worktreePath);
381
391
  const indexFile = tempIndexFile(sessionId, 'create');
392
+ // Both ends of the shutter — see `CreateCheckpointInput.busySessions`.
393
+ const busyBefore = input.busySessions?.() ?? [];
382
394
  const built = await buildIndex(store, worktreePath, indexFile);
383
395
  if (built.tooLarge) {
384
396
  fs.rmSync(indexFile, { force: true });
@@ -386,6 +398,7 @@ export async function createCheckpoint(input) {
386
398
  }
387
399
  const { headSha, included, excluded: skippedFiles, byteCount } = built;
388
400
  const tree = await gitStore(store, worktreePath, indexFile, 'write-tree');
401
+ const busySessions = [...new Set([...busyBefore, ...(input.busySessions?.() ?? [])])].slice(0, MAX_BUSY_SESSIONS);
389
402
  const stagedPaths = splitZ(await gitIn(worktreePath, 'diff', '--cached', '--name-only', '-z')).filter((file) => !isSecretPath(path.join(worktreePath, file)));
390
403
  const meta = {
391
404
  kind,
@@ -397,6 +410,9 @@ export async function createCheckpoint(input) {
397
410
  ...(input.agentAnchor ? { agentAnchor: input.agentAnchor } : {}),
398
411
  ...(input.agentSession ? { agentSession: input.agentSession } : {}),
399
412
  ...(input.messageSeq === undefined ? {} : { messageSeq: input.messageSeq }),
413
+ // Written only when non-empty: an empty array in every ordinary record
414
+ // would be noise in a commit message that is read by eye during support.
415
+ ...(busySessions.length > 0 ? { busySessions } : {}),
400
416
  };
401
417
  const commit = await gitStore(store, worktreePath, indexFile, 'commit-tree', tree, '-m', encodeMeta(meta));
402
418
  const ordinal = await nextOrdinal(store, worktreePath, sessionId);
@@ -466,6 +482,15 @@ async function currentTree(store, worktreePath, indexFile) {
466
482
  headSha: built.headSha,
467
483
  };
468
484
  }
485
+ /**
486
+ * How many neighbour ids a restore point carries (#310).
487
+ *
488
+ * The same number as the API's schema and the runner's event: the mark itself
489
+ * is the warning, the names are a courtesy, and a folder with eleven busy
490
+ * sessions is not eleven times more dangerous than one with ten. Gotcha 433 —
491
+ * a cap on a courtesy must degrade, never reject.
492
+ */
493
+ export const MAX_BUSY_SESSIONS = 10;
469
494
  const MAX_PREVIEW_ENTRIES = 5_000;
470
495
  /** What a rewind to this checkpoint would do, without doing any of it. */
471
496
  export async function previewRewind(input) {
@@ -538,6 +563,13 @@ export async function previewRewind(input) {
538
563
  checkpointHeadSha: record.headSha,
539
564
  treeOid: tree,
540
565
  ...(blockedReason ? { blockedReason } : {}),
566
+ // Presence, not length (gotcha 433): the KEY is the warning, the ids are a
567
+ // courtesy that may resolve to no names at all. A marked point whose list
568
+ // came back empty would otherwise look trustworthy here while `applyRewind`
569
+ // went on refusing it.
570
+ ...(record.busySessions !== undefined
571
+ ? { untrustedFiles: { sessions: record.busySessions } }
572
+ : {}),
541
573
  ...(commitsSince.length ? { commitsSince } : {}),
542
574
  ...(total > MAX_PREVIEW_ENTRIES ? { truncated: true, totalChanges: total } : {}),
543
575
  };
@@ -574,6 +606,12 @@ export async function applyRewind(input) {
574
606
  if (preview.blockedReason) {
575
607
  throw new Error(rewindBlockMessage(preview.blockedReason));
576
608
  }
609
+ // #310. Before the safety point, not after: taking one is a write into the
610
+ // folder, and refusing afterwards would leave a restore point nobody asked
611
+ // for behind every refusal.
612
+ if (preview.untrustedFiles) {
613
+ throw new Error(UNTRUSTED_FILES_MESSAGE);
614
+ }
577
615
  const MOVED = 'The working tree changed while you were looking at it — open the preview again';
578
616
  // The whole state, not just the deletions (QA-120 B1). `read-tree --reset -u`
579
617
  // writes the RESTORE list as well, and that list is recomputed HERE — so a
@@ -586,7 +624,12 @@ export async function applyRewind(input) {
586
624
  if (expected.length !== echoed.length || expected.some((p, i) => p !== echoed[i])) {
587
625
  throw new Error(MOVED);
588
626
  }
589
- const safetyResult = await createCheckpoint({ worktreePath, sessionId, kind: 'SAFETY' });
627
+ const safetyResult = await createCheckpoint({
628
+ worktreePath,
629
+ sessionId,
630
+ kind: 'SAFETY',
631
+ ...(input.busySessions ? { busySessions: input.busySessions } : {}),
632
+ });
590
633
  if (!safetyResult.created) {
591
634
  throw new Error(safetyResult.reason === 'too-large'
592
635
  ? 'The working tree is too large to take a safety point — the rewind was not started'
@@ -617,6 +660,12 @@ export async function applyRewind(input) {
617
660
  rewoundToKind: record.kind,
618
661
  };
619
662
  }
663
+ /**
664
+ * Said when the files of a restore point cannot be trusted (#310). Its own
665
+ * sentence rather than a `blockedReason`, for the reason written on
666
+ * `RewindPreview.untrustedFiles`.
667
+ */
668
+ export const UNTRUSTED_FILES_MESSAGE = 'The files at this restore point cannot be trusted: another session was working in this folder when it was taken. Only the conversation can be rewound to it.';
620
669
  export function rewindBlockMessage(reason) {
621
670
  switch (reason) {
622
671
  case 'head-moved':
package/dist/git.d.ts CHANGED
@@ -1,8 +1,42 @@
1
+ /**
2
+ * Why git stopped, in a sentence a person can act on.
3
+ *
4
+ * `git worktree add` prints its progress to stderr as carriage-return-overwritten
5
+ * frames, so a process killed mid-checkout leaves «Updating files: 1% (51/2693)»
6
+ * as the last thing anybody sees — a number, with no verb. The real cause lives
7
+ * on the error object (`killed`, `signal`, `code`) and nobody was reading it
8
+ * (#360). Keep the last progress frame, because «how far did it get» is the one
9
+ * useful thing in it, and put the cause in front of it.
10
+ */
11
+ export declare function describeGitFailure(error: unknown, context?: {
12
+ timeoutMs?: number;
13
+ }): string;
14
+ /** What went wrong while building the session's workplace, in one machine-readable word. */
15
+ export type WorktreePrepareCode = 'worktree_prepare_failed' | 'branch_taken' | 'worktree_cleanup_failed';
16
+ /**
17
+ * A preparation failure that already knows its own fork point.
18
+ *
19
+ * The fork point matters precisely BECAUSE preparation failed: without it the
20
+ * API keeps `base_sha = null` for the session's whole life, and «Continue» can
21
+ * never prove that the branch it is about to adopt is the empty one this very
22
+ * session created a moment ago (#360, plan Р6).
23
+ */
24
+ export declare class WorktreePrepareError extends Error {
25
+ readonly code: WorktreePrepareCode;
26
+ readonly baseBranch?: string;
27
+ readonly baseSha?: string;
28
+ constructor(message: string, code: WorktreePrepareCode, base?: {
29
+ baseBranch?: string;
30
+ baseSha?: string;
31
+ });
32
+ }
1
33
  export interface PathValidation {
2
34
  ok: boolean;
3
35
  exists: boolean;
4
36
  isGitRepo: boolean;
5
37
  branch?: string;
38
+ /** The repository's own main branch, as this machine can see it without the network. */
39
+ defaultBranch?: string;
6
40
  error?: string;
7
41
  }
8
42
  /**
@@ -16,6 +50,23 @@ export interface PathValidation {
16
50
  * '/opt/ids'». That sentence is true and unactionable.
17
51
  */
18
52
  export declare function validateWorkspacePath(workspacePath: string): Promise<PathValidation>;
53
+ /**
54
+ * The repository's main branch, read locally (ADR 0004).
55
+ *
56
+ * `origin/HEAD` is the honest answer and it is already on disk — set by `clone`,
57
+ * refreshed by `git remote set-head`. Never asked over the network: this runs
58
+ * inside a 4-second probe budget, and a repository whose origin is unreachable
59
+ * must still bind.
60
+ */
61
+ export declare function remoteDefaultBranch(workspacePath: string): Promise<string | null>;
62
+ /**
63
+ * `main`, then `master`, and only if the repository really has one.
64
+ *
65
+ * The order and the list are one decision, not two: `gitops.ts` asks the same
66
+ * question when «Apply» has no pinned base, and two copies would drift the day
67
+ * somebody adds `develop` to one of them (ADR 0004).
68
+ */
69
+ export declare function firstConventionalBranch(workspacePath: string): Promise<string | null>;
19
70
  export declare function sessionShortId(sessionId: string): string;
20
71
  export declare function sessionWorktreePath(sessionId: string): string;
21
72
  /**
@@ -69,6 +120,19 @@ export declare function ensureSessionWorktree(workspacePath: string, sessionId:
69
120
  requireExistingBranch?: boolean;
70
121
  plan?: BranchPlan;
71
122
  }): Promise<SessionWorktree>;
123
+ /**
124
+ * Delete a session branch that provably carries no commits (#360).
125
+ *
126
+ * Shares its safety rungs with {@link deleteSessionBranch} — prune, then the
127
+ * folder's own branch, then the holder — and adds the one this caller needs:
128
+ * the tip must still be exactly the fork point we started from. `-D`, not `-d`,
129
+ * because `-d` asks «is it merged into the branch THIS folder is on», which is
130
+ * a different question and answers «no» for a branch that is empty.
131
+ */
132
+ export declare function dropEmptySessionBranch(workspacePath: string, branch: string, expectedSha: string): Promise<{
133
+ removed: boolean;
134
+ leftover?: string;
135
+ }>;
72
136
  /**
73
137
  * DIRECT mode (session 16): the session's workplace IS the project folder.
74
138
  *