@bridge4dev/runner 0.51.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.
- package/dist/adapters/claude.js +15 -32
- package/dist/adapters/codex.js +103 -26
- package/dist/adapters/types.d.ts +54 -3
- package/dist/adapters/types.js +53 -0
- package/dist/checkpoints.d.ts +62 -0
- package/dist/checkpoints.js +50 -1
- package/dist/git.d.ts +64 -0
- package/dist/git.js +487 -36
- package/dist/gitops.d.ts +5 -0
- package/dist/gitops.js +7 -8
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +228 -7
- package/dist/protocol.d.ts +17 -17
- package/dist/supervisor.d.ts +99 -3
- package/dist/supervisor.js +287 -34
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
-
|
|
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);
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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). */
|
|
@@ -284,6 +287,12 @@ class CodexSession {
|
|
|
284
287
|
sessionId: this.spec.sessionId,
|
|
285
288
|
});
|
|
286
289
|
}
|
|
290
|
+
// Before the thread, not after: a model this CLI no longer offers is
|
|
291
|
+
// refused by `thread/start` itself, and the pin is stored — so the same
|
|
292
|
+
// session would fail again on every relaunch (#374).
|
|
293
|
+
await this.dropModelTheCliNoLongerHas();
|
|
294
|
+
if (this.stopped)
|
|
295
|
+
return;
|
|
287
296
|
const thread = await this.openThread();
|
|
288
297
|
if (this.stopped)
|
|
289
298
|
return;
|
|
@@ -799,17 +808,7 @@ class CodexSession {
|
|
|
799
808
|
for (const [requestId, pending] of [...this.approvals]) {
|
|
800
809
|
if (!pending.policy)
|
|
801
810
|
continue;
|
|
802
|
-
const verdict = evaluateToolUse(pending.policy.tool, pending.policy.input,
|
|
803
|
-
trustMode: this.spec.trustMode,
|
|
804
|
-
mode: this.mode,
|
|
805
|
-
...(this.spec.agentAutoCommit === undefined
|
|
806
|
-
? {}
|
|
807
|
-
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
808
|
-
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
809
|
-
// Session 18 — spread whole; see the note in the Claude adapter.
|
|
810
|
-
...(this.spec.gitPolicy ?? {}),
|
|
811
|
-
worktreePath: this.spec.cwd,
|
|
812
|
-
});
|
|
811
|
+
const verdict = evaluateToolUse(pending.policy.tool, pending.policy.input, policyContextFor(this.spec, this.mode));
|
|
813
812
|
if (verdict.decision !== 'allow')
|
|
814
813
|
continue;
|
|
815
814
|
this.approvals.delete(requestId);
|
|
@@ -924,19 +923,7 @@ class CodexSession {
|
|
|
924
923
|
const enriched = this.describeApproval(request);
|
|
925
924
|
const verdict = enriched.forceAsk
|
|
926
925
|
? { decision: 'ask', reason: 'details unavailable' }
|
|
927
|
-
: evaluateToolUse(enriched.policyTool, enriched.policyInput,
|
|
928
|
-
trustMode: this.spec.trustMode,
|
|
929
|
-
// Ticket #156, the same missing argument as in the Claude adapter —
|
|
930
|
-
// both bridges call one policy, so both have to hand it the mode.
|
|
931
|
-
mode: this.mode,
|
|
932
|
-
...(this.spec.agentAutoCommit === undefined
|
|
933
|
-
? {}
|
|
934
|
-
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
935
|
-
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
936
|
-
// Session 18 — spread whole; see the note in the Claude adapter.
|
|
937
|
-
...(this.spec.gitPolicy ?? {}),
|
|
938
|
-
worktreePath: this.spec.cwd,
|
|
939
|
-
});
|
|
926
|
+
: evaluateToolUse(enriched.policyTool, enriched.policyInput, policyContextFor(this.spec, this.mode));
|
|
940
927
|
if (verdict.decision === 'allow') {
|
|
941
928
|
this.client.respond(request.id, { decision: 'accept' });
|
|
942
929
|
return;
|
|
@@ -962,7 +949,10 @@ class CodexSession {
|
|
|
962
949
|
requestId,
|
|
963
950
|
toolName: enriched.toolName,
|
|
964
951
|
title: enriched.title,
|
|
965
|
-
|
|
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
|
+
: {}),
|
|
966
956
|
input: enriched.input,
|
|
967
957
|
});
|
|
968
958
|
}
|
|
@@ -1915,6 +1905,93 @@ class CodexSession {
|
|
|
1915
1905
|
};
|
|
1916
1906
|
this.emit({ type: 'capabilities', capabilities });
|
|
1917
1907
|
}
|
|
1908
|
+
/**
|
|
1909
|
+
* A pinned model the installed CLI does not have is dropped before it can
|
|
1910
|
+
* fail the session (#374).
|
|
1911
|
+
*
|
|
1912
|
+
* The pin is chosen from a catalogue that was measured the last time an agent
|
|
1913
|
+
* ran on this machine, and the CLI moves underneath it: codex 0.153.4 gained
|
|
1914
|
+
* `gpt-6-astra` and lost `gpt-5.3-codex-spark`, so a machine that had not run
|
|
1915
|
+
* a session since the update was offering — and storing — a model that no
|
|
1916
|
+
* longer exists. `turn/start` does not warn about that, it refuses, and since
|
|
1917
|
+
* the pin is persisted the refusal repeats on every relaunch.
|
|
1918
|
+
*
|
|
1919
|
+
* Asked of `model/list` rather than recognised in an error message: matching
|
|
1920
|
+
* on text is what the retry rules were explicitly moved away from in 0.44.1,
|
|
1921
|
+
* because the wording is the vendor's to change.
|
|
1922
|
+
*
|
|
1923
|
+
* A probe that itself fails changes nothing. Diagnosing the start must not be
|
|
1924
|
+
* able to prevent it — an unreachable `model/list` on a CLI that would have
|
|
1925
|
+
* accepted the model would otherwise cost the user their choice.
|
|
1926
|
+
*/
|
|
1927
|
+
async dropModelTheCliNoLongerHas() {
|
|
1928
|
+
const pinned = this.model;
|
|
1929
|
+
if (!pinned)
|
|
1930
|
+
return;
|
|
1931
|
+
let known;
|
|
1932
|
+
try {
|
|
1933
|
+
known = await this.probeModelIds();
|
|
1934
|
+
}
|
|
1935
|
+
catch (error) {
|
|
1936
|
+
log.warn('codex: could not check the pinned model — leaving it alone', {
|
|
1937
|
+
sessionId: this.spec.sessionId,
|
|
1938
|
+
error: describe(error),
|
|
1939
|
+
});
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
// Only a COMPLETE list can convict a model. An empty answer means this build
|
|
1943
|
+
// does not really implement `model/list`; a truncated one means the answer
|
|
1944
|
+
// continues on a page we did not ask for. Both must read as «no verdict»,
|
|
1945
|
+
// or the check invents an absence and takes the user's choice away.
|
|
1946
|
+
if (!known.complete || known.ids.size === 0)
|
|
1947
|
+
return;
|
|
1948
|
+
if (known.ids.has(pinned))
|
|
1949
|
+
return;
|
|
1950
|
+
delete this.model;
|
|
1951
|
+
// Said out loud, because the session is about to run on a different model
|
|
1952
|
+
// than the one that was picked — silently substituting it would be worse
|
|
1953
|
+
// than the failure this replaces.
|
|
1954
|
+
this.notice('warn', `This server's codex no longer offers ${pinned} — starting on the agent's own default instead.`);
|
|
1955
|
+
// Cleared everywhere, not just here: the API stores the pin and hands it
|
|
1956
|
+
// back in the next relaunch descriptor, so a pin dropped only in memory
|
|
1957
|
+
// comes back on the next restart (the same trap as QA-100 MAJOR-5).
|
|
1958
|
+
//
|
|
1959
|
+
// What the column ends up holding is «nothing pinned» only until the thread
|
|
1960
|
+
// opens: the capabilities frame right after it reports `currentModel`, and
|
|
1961
|
+
// with no pin that is the thread's own model. Either way the invariant the
|
|
1962
|
+
// clearing exists for holds — what is stored is a model this CLI has.
|
|
1963
|
+
this.emit({ type: 'settings', model: null });
|
|
1964
|
+
if (this.effort) {
|
|
1965
|
+
// The level was chosen for a model that is not there. Keeping it would
|
|
1966
|
+
// apply «Ultra» to whatever the CLI defaults to, which may not have it.
|
|
1967
|
+
delete this.effort;
|
|
1968
|
+
this.emit({ type: 'settings', effort: null });
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Every model id this CLI knows, for the pin check only — including the ones
|
|
1973
|
+
* `listModels()` filters out of the PICKER.
|
|
1974
|
+
*
|
|
1975
|
+
* `hidden` means «do not offer this», not «this does not exist»: codex ships
|
|
1976
|
+
* internal entries like `codex-auto-review` that way, and a pin is not an
|
|
1977
|
+
* offer. Judging a stored pin by the picker's list would delete a model the
|
|
1978
|
+
* CLI still accepts.
|
|
1979
|
+
*
|
|
1980
|
+
* `complete` is false when the answer is paged, and a paged answer proves
|
|
1981
|
+
* nothing about what is missing.
|
|
1982
|
+
*/
|
|
1983
|
+
async probeModelIds() {
|
|
1984
|
+
const result = asRecord(await this.client.request('model/list', { limit: 50 }, 15_000));
|
|
1985
|
+
const data = Array.isArray(result['data']) ? result['data'] : [];
|
|
1986
|
+
const ids = new Set();
|
|
1987
|
+
for (const entry of data) {
|
|
1988
|
+
const row = asRecord(entry);
|
|
1989
|
+
const id = str(row['id']) ?? str(row['model']);
|
|
1990
|
+
if (id)
|
|
1991
|
+
ids.add(id);
|
|
1992
|
+
}
|
|
1993
|
+
return { ids, complete: !str(result['nextCursor']) };
|
|
1994
|
+
}
|
|
1918
1995
|
async listModels() {
|
|
1919
1996
|
const result = asRecord(await this.client.request('model/list', { limit: 50 }, 15_000));
|
|
1920
1997
|
const data = Array.isArray(result['data']) ? result['data'] : [];
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -406,9 +450,16 @@ export type AgentEvent = {
|
|
|
406
450
|
} | {
|
|
407
451
|
type: 'capabilities';
|
|
408
452
|
capabilities: AgentCapabilities;
|
|
409
|
-
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* `model: null` — the pin is GONE, not merely unchanged: the installed CLI
|
|
456
|
+
* stopped offering it (#374). Spelled like `effort: null` and read the same
|
|
457
|
+
* way by the API, which clears the stored column instead of handing the dead
|
|
458
|
+
* pin back in the next descriptor.
|
|
459
|
+
*/
|
|
460
|
+
| {
|
|
410
461
|
type: 'settings';
|
|
411
|
-
model?: string;
|
|
462
|
+
model?: string | null;
|
|
412
463
|
mode?: AgentMode;
|
|
413
464
|
effort?: string | null;
|
|
414
465
|
} | {
|
package/dist/adapters/types.js
CHANGED
|
@@ -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
|
*
|
package/dist/checkpoints.d.ts
CHANGED
|
@@ -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>;
|
package/dist/checkpoints.js
CHANGED
|
@@ -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({
|
|
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':
|