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