@bridge4dev/runner 0.26.0 → 0.29.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 +136 -8
- package/dist/adapters/codex.js +96 -2
- package/dist/adapters/types.d.ts +60 -1
- package/dist/agent-auth.d.ts +29 -0
- package/dist/agent-auth.js +136 -0
- package/dist/attachments.d.ts +27 -0
- package/dist/attachments.js +150 -8
- package/dist/auth-relay.d.ts +29 -1
- package/dist/auth-relay.js +228 -13
- package/dist/checkpoints.d.ts +175 -0
- package/dist/checkpoints.js +816 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -0
- package/dist/environment.d.ts +15 -0
- package/dist/environment.js +25 -1
- package/dist/index.js +176 -10
- package/dist/journal.d.ts +34 -1
- package/dist/journal.js +51 -2
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +12 -0
- package/dist/policy.js +15 -0
- package/dist/protocol.d.ts +10 -10
- package/dist/recipe-schema.d.ts +1 -1
- package/dist/self-update.d.ts +43 -2
- package/dist/self-update.js +137 -43
- package/dist/supervisor.d.ts +86 -0
- package/dist/supervisor.js +555 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -64,6 +64,25 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
64
64
|
}, {
|
|
65
65
|
enabled?: boolean | undefined;
|
|
66
66
|
}>>;
|
|
67
|
+
/**
|
|
68
|
+
* Ticket #126: the machine owner's veto over restore points.
|
|
69
|
+
*
|
|
70
|
+
* A restore point is a copy of the working tree, kept on this machine, in an
|
|
71
|
+
* object store of the runner's own. That is disk this server's owner pays
|
|
72
|
+
* for and content they may not want duplicated at all — so the same rule as
|
|
73
|
+
* `[verify]` applies: `enabled = false` means the capability is not
|
|
74
|
+
* announced, the dashboard draws no rewind action, and no snapshot is ever
|
|
75
|
+
* taken. Secrets are excluded from a checkpoint in any case (`policy.ts`
|
|
76
|
+
* decides what counts), but "excluded" is a promise, and switching the whole
|
|
77
|
+
* thing off is a fact.
|
|
78
|
+
*/
|
|
79
|
+
checkpoints: z.ZodOptional<z.ZodObject<{
|
|
80
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
81
|
+
}, "strip", z.ZodTypeAny, {
|
|
82
|
+
enabled: boolean;
|
|
83
|
+
}, {
|
|
84
|
+
enabled?: boolean | undefined;
|
|
85
|
+
}>>;
|
|
67
86
|
}, "strip", z.ZodTypeAny, {
|
|
68
87
|
api: {
|
|
69
88
|
url: string;
|
|
@@ -74,6 +93,9 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
74
93
|
name: string;
|
|
75
94
|
token: string;
|
|
76
95
|
};
|
|
96
|
+
checkpoints?: {
|
|
97
|
+
enabled: boolean;
|
|
98
|
+
} | undefined;
|
|
77
99
|
mcp?: {
|
|
78
100
|
url: string;
|
|
79
101
|
token: string;
|
|
@@ -97,6 +119,9 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
97
119
|
name: string;
|
|
98
120
|
token: string;
|
|
99
121
|
};
|
|
122
|
+
checkpoints?: {
|
|
123
|
+
enabled?: boolean | undefined;
|
|
124
|
+
} | undefined;
|
|
100
125
|
mcp?: {
|
|
101
126
|
url: string;
|
|
102
127
|
token: string;
|
package/dist/config.js
CHANGED
|
@@ -59,6 +59,23 @@ const ConfigSchema = z.object({
|
|
|
59
59
|
enabled: z.boolean().default(true),
|
|
60
60
|
})
|
|
61
61
|
.optional(),
|
|
62
|
+
/**
|
|
63
|
+
* Ticket #126: the machine owner's veto over restore points.
|
|
64
|
+
*
|
|
65
|
+
* A restore point is a copy of the working tree, kept on this machine, in an
|
|
66
|
+
* object store of the runner's own. That is disk this server's owner pays
|
|
67
|
+
* for and content they may not want duplicated at all — so the same rule as
|
|
68
|
+
* `[verify]` applies: `enabled = false` means the capability is not
|
|
69
|
+
* announced, the dashboard draws no rewind action, and no snapshot is ever
|
|
70
|
+
* taken. Secrets are excluded from a checkpoint in any case (`policy.ts`
|
|
71
|
+
* decides what counts), but "excluded" is a promise, and switching the whole
|
|
72
|
+
* thing off is a fact.
|
|
73
|
+
*/
|
|
74
|
+
checkpoints: z
|
|
75
|
+
.object({
|
|
76
|
+
enabled: z.boolean().default(true),
|
|
77
|
+
})
|
|
78
|
+
.optional(),
|
|
62
79
|
});
|
|
63
80
|
export function loadConfig() {
|
|
64
81
|
const file = configFilePath();
|
package/dist/environment.d.ts
CHANGED
|
@@ -77,6 +77,20 @@ export interface AgentConfigContour {
|
|
|
77
77
|
plugins: boolean;
|
|
78
78
|
codexDir: boolean;
|
|
79
79
|
codexConfig: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* MCP servers an agent session in this home will actually see, and the ones
|
|
82
|
+
* that are configured but invisible to it.
|
|
83
|
+
*
|
|
84
|
+
* Both numbers, because the difference IS the bug. `claude mcp add` defaults
|
|
85
|
+
* to LOCAL scope, which stores the server under
|
|
86
|
+
* `~/.claude.json → projects["<cwd>"].mcpServers` — keyed by the directory it
|
|
87
|
+
* was added from. Runner sessions work in a per-session git worktree, so that
|
|
88
|
+
* key never matches and the servers are simply absent. On a live machine this
|
|
89
|
+
* read as «the agent lost Playwright and Context7», and the only cure was
|
|
90
|
+
* moving them to user scope (the top-level `mcpServers`).
|
|
91
|
+
*/
|
|
92
|
+
mcpUserScope: number;
|
|
93
|
+
mcpProjectScope: number;
|
|
80
94
|
}
|
|
81
95
|
export declare function agentConfigContour(home?: string): AgentConfigContour;
|
|
82
96
|
/**
|
|
@@ -116,6 +130,7 @@ export interface ToolCheck {
|
|
|
116
130
|
/** Set when the tool is there but this user cannot use it. */
|
|
117
131
|
problem?: string;
|
|
118
132
|
}
|
|
133
|
+
export declare function whichExecutable(name: string): string | null;
|
|
119
134
|
/**
|
|
120
135
|
* Node, as THIS user sees it.
|
|
121
136
|
*
|
package/dist/environment.js
CHANGED
|
@@ -114,6 +114,27 @@ export async function addSafeDirectory(repoPath) {
|
|
|
114
114
|
timeout: 10_000,
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Count MCP servers in the CLI's own config file, by scope.
|
|
119
|
+
*
|
|
120
|
+
* Reads `~/.claude.json` directly rather than shelling out to `claude mcp
|
|
121
|
+
* list`: this is a diagnostic that must work when the CLI is missing, and it
|
|
122
|
+
* must not spend ~300 MB and a second of a doctor run to answer.
|
|
123
|
+
*/
|
|
124
|
+
function countMcpServers(home) {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'));
|
|
127
|
+
const user = Object.keys(parsed.mcpServers ?? {}).length;
|
|
128
|
+
let project = 0;
|
|
129
|
+
for (const entry of Object.values(parsed.projects ?? {})) {
|
|
130
|
+
project += Object.keys(entry?.mcpServers ?? {}).length;
|
|
131
|
+
}
|
|
132
|
+
return { user, project };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { user: 0, project: 0 };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
117
138
|
function countAllowRules(file) {
|
|
118
139
|
try {
|
|
119
140
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -152,6 +173,7 @@ export function agentConfigContour(home = os.homedir()) {
|
|
|
152
173
|
const localSettings = path.join(claudeDir, 'settings.local.json');
|
|
153
174
|
const commandsDir = path.join(claudeDir, 'commands');
|
|
154
175
|
const codexDir = path.join(home, '.codex');
|
|
176
|
+
const mcp = countMcpServers(home);
|
|
155
177
|
return {
|
|
156
178
|
home,
|
|
157
179
|
claudeDir: fs.existsSync(claudeDir),
|
|
@@ -166,6 +188,8 @@ export function agentConfigContour(home = os.homedir()) {
|
|
|
166
188
|
plugins: fs.existsSync(path.join(claudeDir, 'plugins')),
|
|
167
189
|
codexDir: fs.existsSync(codexDir),
|
|
168
190
|
codexConfig: fs.existsSync(path.join(codexDir, 'config.toml')),
|
|
191
|
+
mcpUserScope: mcp.user,
|
|
192
|
+
mcpProjectScope: mcp.project,
|
|
169
193
|
};
|
|
170
194
|
}
|
|
171
195
|
/**
|
|
@@ -266,7 +290,7 @@ export function knownWorkspacePaths() {
|
|
|
266
290
|
return [];
|
|
267
291
|
}
|
|
268
292
|
}
|
|
269
|
-
function whichExecutable(name) {
|
|
293
|
+
export function whichExecutable(name) {
|
|
270
294
|
for (const dir of (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) {
|
|
271
295
|
const candidate = path.join(dir, name);
|
|
272
296
|
try {
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,8 @@ import { CodexAdapter } from './adapters/codex.js';
|
|
|
10
10
|
import { ensureCodexHome } from './adapters/codex-home.js';
|
|
11
11
|
import { loadConfig, requireConfig, saveConfig } from './config.js';
|
|
12
12
|
import { log } from './log.js';
|
|
13
|
-
import { installIsWritable, isSupervisedProcess, resolveInstalledPackageDir, } from './self-update.js';
|
|
13
|
+
import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
|
|
14
|
+
import { applyStoredClaudeToken } from './agent-auth.js';
|
|
14
15
|
import { Supervisor } from './supervisor.js';
|
|
15
16
|
import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
|
|
16
17
|
import { RunnerWsClient } from './ws-client.js';
|
|
@@ -103,13 +104,20 @@ function hasExecutable(name) {
|
|
|
103
104
|
* older runner drops a frame it cannot parse without ever replying, so a
|
|
104
105
|
* command it does not know would just hang until the gateway timeout.
|
|
105
106
|
*/
|
|
106
|
-
function runnerCapabilities() {
|
|
107
|
+
function runnerCapabilities(apiUrlOverride) {
|
|
107
108
|
const config = loadConfig();
|
|
109
|
+
// At PAIR time there is no config yet, so the API this runner is about to
|
|
110
|
+
// belong to has to be passed in — otherwise the very first `hello` (the one
|
|
111
|
+
// that creates the server record) would omit the update command, and the
|
|
112
|
+
// dashboard would show a stale one until the daemon reconnected.
|
|
113
|
+
const apiUrl = apiUrlOverride ?? config?.api.url;
|
|
108
114
|
const localLimit = config?.limits?.max_sessions;
|
|
109
115
|
// Session 14: the machine owner's veto. Default on — the safety of the
|
|
110
116
|
// feature is that a DevBridge manager approves every command first — but the
|
|
111
117
|
// person who owns the server gets the last word on whether it exists here.
|
|
112
118
|
const verifyEnabled = config?.verify?.enabled !== false;
|
|
119
|
+
// Ticket #126: the same veto, for restore points. Default on.
|
|
120
|
+
const checkpointsEnabled = config?.checkpoints?.enabled !== false;
|
|
113
121
|
return {
|
|
114
122
|
agents: installedAgents(),
|
|
115
123
|
git: true,
|
|
@@ -135,6 +143,37 @@ function runnerCapabilities() {
|
|
|
135
143
|
* is only half the instruction without a name to restart under.
|
|
136
144
|
*/
|
|
137
145
|
runnerUser: runnerIdentity().user,
|
|
146
|
+
/**
|
|
147
|
+
* Which agent CLIs are actually on this user's PATH (0.27.0).
|
|
148
|
+
*
|
|
149
|
+
* Different question from `agents` above, and the difference cost a
|
|
150
|
+
* support round: `agents` says which agents can RUN sessions (Claude
|
|
151
|
+
* always can — the SDK bundles its own binary), while signing in needs the
|
|
152
|
+
* standalone CLI to exist for this user. On a dedicated-user install it
|
|
153
|
+
* routinely does not, and the sign-in button then failed with an error
|
|
154
|
+
* about `script`, about a machine whose real problem was that nobody had
|
|
155
|
+
* installed `claude` for that user at all.
|
|
156
|
+
*/
|
|
157
|
+
agentClis: {
|
|
158
|
+
claude: hasExecutable('claude'),
|
|
159
|
+
codex: hasExecutable('codex'),
|
|
160
|
+
},
|
|
161
|
+
/**
|
|
162
|
+
* Where npm put this package, and the command that updates it here
|
|
163
|
+
* (0.27.0).
|
|
164
|
+
*
|
|
165
|
+
* The dashboard used to assemble the update command itself and then patch
|
|
166
|
+
* it with string surgery for the dedicated-user case. It cannot: only this
|
|
167
|
+
* process knows the prefix it was installed into, and `npm install -g`
|
|
168
|
+
* without that prefix is precisely the EACCES the owner pasted. So the
|
|
169
|
+
* machine states its own command and the panel just shows it.
|
|
170
|
+
*/
|
|
171
|
+
...(installPrefixFor() ? { npmPrefix: installPrefixFor() } : {}),
|
|
172
|
+
...(apiUrl
|
|
173
|
+
? {
|
|
174
|
+
updateCommand: manualUpdateCommand(`${apiUrl.replace(/\/$/, '')}/api/v1/dev-setup/runner.tgz`),
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
138
177
|
/**
|
|
139
178
|
* A stricter ceiling set on the machine itself (layer 1). Reported so the
|
|
140
179
|
* dashboard can explain why raising the number there changed nothing.
|
|
@@ -194,6 +233,22 @@ function runnerCapabilities() {
|
|
|
194
233
|
...(verifyEnabled ? { previewWorktrees: true } : {}),
|
|
195
234
|
/** Session 14: one-shot agent-written commit messages. */
|
|
196
235
|
commitMessages: true,
|
|
236
|
+
/**
|
|
237
|
+
* Ticket #126: restore points for the working tree, and the rewind that
|
|
238
|
+
* uses them. Withheld — not reported as false — when the machine's owner
|
|
239
|
+
* switched them off, by the same rule as `verifyRuns`.
|
|
240
|
+
*/
|
|
241
|
+
...(checkpointsEnabled
|
|
242
|
+
? { sessionCheckpoints: true }
|
|
243
|
+
: { checkpointsBlocked: 'disabled-by-config' }),
|
|
244
|
+
/**
|
|
245
|
+
* Ticket #126: rewinding what the AGENT remembers, and `/compact` on
|
|
246
|
+
* demand. Both agents can do both — Claude through `resumeSessionAt` +
|
|
247
|
+
* `forkSession`, Codex through the stable `thread/fork { lastTurnId }` and
|
|
248
|
+
* `thread/compact/start` of app-server 0.145.0.
|
|
249
|
+
*/
|
|
250
|
+
contextRewind: true,
|
|
251
|
+
contextCompaction: true,
|
|
197
252
|
/**
|
|
198
253
|
* Session 15: `git_status` reports whether the PROJECT FOLDER is clean, so
|
|
199
254
|
* the panel can say what is blocking an Apply instead of offering a button
|
|
@@ -284,6 +339,17 @@ function runnerCapabilities() {
|
|
|
284
339
|
'git_merge_abort',
|
|
285
340
|
'recipe_state',
|
|
286
341
|
'propose_commit_message',
|
|
342
|
+
'recall_message',
|
|
343
|
+
'compact_context',
|
|
344
|
+
...(checkpointsEnabled
|
|
345
|
+
? [
|
|
346
|
+
'session_checkpoint',
|
|
347
|
+
'session_checkpoints',
|
|
348
|
+
'session_rewind_preview',
|
|
349
|
+
'session_rewind_files',
|
|
350
|
+
'context_rewind',
|
|
351
|
+
]
|
|
352
|
+
: []),
|
|
287
353
|
...(verifyEnabled
|
|
288
354
|
? ['verify_start', 'verify_status', 'verify_cancel', 'preview_checkout', 'preview_stop']
|
|
289
355
|
: []),
|
|
@@ -306,7 +372,7 @@ async function cmdPair(args) {
|
|
|
306
372
|
name,
|
|
307
373
|
runnerVersion: RUNNER_VERSION,
|
|
308
374
|
osInfo: `${os.type()} ${os.release()} ${os.arch()}`.slice(0, 200),
|
|
309
|
-
capabilities: runnerCapabilities(),
|
|
375
|
+
capabilities: runnerCapabilities(apiUrl),
|
|
310
376
|
}),
|
|
311
377
|
});
|
|
312
378
|
const body = (await response.json().catch(() => null));
|
|
@@ -429,6 +495,12 @@ async function cmdDaemon() {
|
|
|
429
495
|
log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
|
|
430
496
|
sweepOrphanedMcpConfigs();
|
|
431
497
|
await repairResourceLimits();
|
|
498
|
+
// A Claude token this runner captured through the sign-in relay. Applied
|
|
499
|
+
// BEFORE any adapter exists, because `scrubbedEnv()` copies it out of this
|
|
500
|
+
// process's environment for every session it starts.
|
|
501
|
+
if (applyStoredClaudeToken()) {
|
|
502
|
+
log.info('daemon: using the Claude token stored on this server');
|
|
503
|
+
}
|
|
432
504
|
const agents = installedAgents();
|
|
433
505
|
const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
|
|
434
506
|
const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
|
|
@@ -442,6 +514,7 @@ async function cmdDaemon() {
|
|
|
442
514
|
// The same veto the capability list honours — announced AND enforced, so a
|
|
443
515
|
// frame from an API that has not noticed still cannot start a run.
|
|
444
516
|
verifyEnabled: config.verify?.enabled !== false,
|
|
517
|
+
checkpointsEnabled: config.checkpoints?.enabled !== false,
|
|
445
518
|
apiUrl: config.api.url,
|
|
446
519
|
// Used to fetch the files a user attaches to a message (session 10) — the
|
|
447
520
|
// same token the WS connection authenticates with, never passed onwards.
|
|
@@ -654,8 +727,16 @@ async function runnerChecks() {
|
|
|
654
727
|
const state = await systemctlProperty('ActiveState');
|
|
655
728
|
const enabled = await systemctlProperty('UnitFileState');
|
|
656
729
|
const running = state === 'active';
|
|
730
|
+
// Three separate promises, and the verdict has to fail on any of them.
|
|
731
|
+
// Until 0.27.0 only the first counted: a service that was running but
|
|
732
|
+
// would die at the next logout (linger off) or never come back after a
|
|
733
|
+
// reboot (not enabled) still printed ✔ and still said READY, with the
|
|
734
|
+
// `loginctl enable-linger` line sitting UNDER the tick as if it were
|
|
735
|
+
// advice. The whole point of an acceptance sheet is that a green one means
|
|
736
|
+
// walk away — so a runner that stops when its user logs out is a red line.
|
|
737
|
+
const durable = running && enabled === 'enabled' && linger !== false;
|
|
657
738
|
checks.push({
|
|
658
|
-
ok:
|
|
739
|
+
ok: durable,
|
|
659
740
|
name: 'service',
|
|
660
741
|
detail: running
|
|
661
742
|
? `running${enabled === 'enabled' ? ', starts on boot' : ' — but NOT enabled: it will not come back after a reboot'}` +
|
|
@@ -663,7 +744,7 @@ async function runnerChecks() {
|
|
|
663
744
|
? ', survives logout'
|
|
664
745
|
: linger === false
|
|
665
746
|
? ', but linger is OFF: it stops when this user logs out'
|
|
666
|
-
: '')
|
|
747
|
+
: ', linger state unknown')
|
|
667
748
|
: `NOT running (${state ?? 'unknown'})`,
|
|
668
749
|
...(running
|
|
669
750
|
? enabled !== 'enabled'
|
|
@@ -725,10 +806,27 @@ async function runnerChecks() {
|
|
|
725
806
|
...(canUpdate || packageDir === null
|
|
726
807
|
? {}
|
|
727
808
|
: {
|
|
728
|
-
|
|
729
|
-
|
|
809
|
+
// NOT `--prefix ~/.local`, which is what this line used to say. The
|
|
810
|
+
// tilde is expanded by the CALLING shell, so run as root it aimed at
|
|
811
|
+
// /root/.local — a directory the daemon's user cannot write — and
|
|
812
|
+
// the install died naming a home nobody had chosen. `sudo -iu` hands
|
|
813
|
+
// the string to the TARGET user's login shell, so `$HOME` is theirs.
|
|
814
|
+
fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --loglevel=error @bridge4dev/runner'`,
|
|
815
|
+
fixMore: `(the \`npm config set prefix\` half is what keeps the button working: without it every LATER update aims at the system prefix again and fails with EACCES)`,
|
|
730
816
|
}),
|
|
731
817
|
});
|
|
818
|
+
// The relay that signs an agent in runs the CLI on a pty, and util-linux
|
|
819
|
+
// `script` is what allocates it. Missing on minimal images, and its absence
|
|
820
|
+
// used to surface as «claude login exited before printing a sign-in URL».
|
|
821
|
+
const hasScript = hasExecutable('script');
|
|
822
|
+
checks.push({
|
|
823
|
+
ok: hasScript,
|
|
824
|
+
name: 'sign-in relay',
|
|
825
|
+
detail: hasScript
|
|
826
|
+
? 'ready (util-linux `script` present)'
|
|
827
|
+
: '`script` (util-linux) is missing — the dashboard sign-in button cannot run',
|
|
828
|
+
...(hasScript ? {} : { fix: 'apt-get install -y bsdextrautils util-linux' }),
|
|
829
|
+
});
|
|
732
830
|
return checks;
|
|
733
831
|
}
|
|
734
832
|
async function agentChecks() {
|
|
@@ -744,12 +842,45 @@ async function agentChecks() {
|
|
|
744
842
|
process.env['DEVBRIDGE_RUNNER_LOG'] = previousLogLevel;
|
|
745
843
|
});
|
|
746
844
|
const checks = [];
|
|
845
|
+
// Does the CLI exist for THIS user, before asking whether it is signed in?
|
|
846
|
+
// «not signed in» over a machine that has no `claude` at all sends the
|
|
847
|
+
// reader to a login screen for a command that does not exist — and doctor's
|
|
848
|
+
// own remedy (`sudo -iu <user> claude`) was then `command not found`. That
|
|
849
|
+
// is the state a dedicated-user install leaves behind by default.
|
|
850
|
+
const cliPresent = {
|
|
851
|
+
claude: hasExecutable('claude'),
|
|
852
|
+
codex: hasExecutable('codex'),
|
|
853
|
+
};
|
|
854
|
+
checks.push({
|
|
855
|
+
ok: cliPresent.claude,
|
|
856
|
+
name: 'claude cli',
|
|
857
|
+
detail: cliPresent.claude
|
|
858
|
+
? `on ${me.user}'s PATH`
|
|
859
|
+
: `not installed for ${me.user} — sessions still run (the SDK bundles its own), but signing in needs the CLI`,
|
|
860
|
+
...(cliPresent.claude
|
|
861
|
+
? {}
|
|
862
|
+
: {
|
|
863
|
+
fix: `sudo -iu ${me.user} sh -lc 'curl -fsSL https://claude.ai/install.sh | bash'`,
|
|
864
|
+
}),
|
|
865
|
+
});
|
|
866
|
+
// Codex is optional: plenty of machines only ever run Claude sessions, and a
|
|
867
|
+
// red line for an agent nobody uses is noise that trains people to ignore
|
|
868
|
+
// the sheet. Reported, not judged.
|
|
869
|
+
checks.push({
|
|
870
|
+
ok: true,
|
|
871
|
+
name: 'codex cli',
|
|
872
|
+
detail: cliPresent.codex ? `on ${me.user}'s PATH` : `not installed for ${me.user} (optional)`,
|
|
873
|
+
...(cliPresent.codex
|
|
874
|
+
? {}
|
|
875
|
+
: {
|
|
876
|
+
fix: `sudo -iu ${me.user} sh -lc 'npm install -g @openai/codex' # only if you use Codex`,
|
|
877
|
+
}),
|
|
878
|
+
});
|
|
747
879
|
for (const [agent, info] of [
|
|
748
880
|
['claude', auth.claude],
|
|
749
881
|
['codex', auth.codex],
|
|
750
882
|
]) {
|
|
751
883
|
const signedIn = info.status === 'ok';
|
|
752
|
-
const command = agent === 'claude' ? 'claude' : 'codex login';
|
|
753
884
|
checks.push({
|
|
754
885
|
ok: signedIn,
|
|
755
886
|
name: `${agent} login`,
|
|
@@ -758,12 +889,39 @@ async function agentChecks() {
|
|
|
758
889
|
...(signedIn
|
|
759
890
|
? {}
|
|
760
891
|
: {
|
|
761
|
-
|
|
892
|
+
// The dashboard button FIRST. It is the product's own path, it
|
|
893
|
+
// needs no shell on the server, and an installing agent cannot
|
|
894
|
+
// perform an interactive OAuth login anyway — so telling it to
|
|
895
|
+
// «run claude and do /login» is telling it to stop. That is
|
|
896
|
+
// exactly where the last three installs stopped.
|
|
897
|
+
fix: `Dashboard → Development → this server → AGENTS → «Sign in» next to ${agent === 'claude' ? 'Claude Code' : 'Codex'}`,
|
|
898
|
+
fixMore: cliPresent[agent]
|
|
899
|
+
? `or on the server: ${me.isRoot ? '' : `sudo -iu ${me.user} `}${agent === 'claude' ? 'claude auth login' : 'codex login'}`
|
|
900
|
+
: `(install the CLI first — see the «${agent} cli» line above)`,
|
|
762
901
|
}),
|
|
763
902
|
});
|
|
764
903
|
}
|
|
765
904
|
const contour = agentConfigContour(me.home);
|
|
766
905
|
const elsewhere = otherHomeWithAgents(me);
|
|
906
|
+
// MCP servers a session will really see. Only user scope survives a session
|
|
907
|
+
// worktree, and «configured but in the wrong scope» looks identical to
|
|
908
|
+
// «working» from anywhere except inside a session.
|
|
909
|
+
const mcpHidden = contour.mcpUserScope === 0 && contour.mcpProjectScope > 0;
|
|
910
|
+
checks.push({
|
|
911
|
+
ok: !mcpHidden,
|
|
912
|
+
name: 'mcp servers',
|
|
913
|
+
detail: mcpHidden
|
|
914
|
+
? `${contour.mcpProjectScope} configured, but all per-directory — a session works in its own worktree and will see NONE`
|
|
915
|
+
: contour.mcpUserScope > 0
|
|
916
|
+
? `${contour.mcpUserScope} available to every session`
|
|
917
|
+
: 'none configured (the DevBridge server is injected per session regardless)',
|
|
918
|
+
...(mcpHidden
|
|
919
|
+
? {
|
|
920
|
+
fix: `sudo -iu ${me.user} claude mcp add --scope user <name> … # re-add at user scope`,
|
|
921
|
+
fixMore: '(`claude mcp add` defaults to the current directory’s scope, which no session shares)',
|
|
922
|
+
}
|
|
923
|
+
: {}),
|
|
924
|
+
});
|
|
767
925
|
checks.push({
|
|
768
926
|
ok: contour.claudeDir,
|
|
769
927
|
name: 'claude config',
|
|
@@ -851,7 +1009,15 @@ async function projectChecks(target, fix) {
|
|
|
851
1009
|
name: 'docker',
|
|
852
1010
|
detail: docker.problem ?? 'usable by this user',
|
|
853
1011
|
...(docker.problem
|
|
854
|
-
? {
|
|
1012
|
+
? {
|
|
1013
|
+
fix: `usermod -aG docker ${me.user}`,
|
|
1014
|
+
// Restarting the unit is NOT enough and this cost a diagnosis:
|
|
1015
|
+
// supplementary groups are fixed when logind creates
|
|
1016
|
+
// user@<uid>.service, so the daemon keeps the old set until the
|
|
1017
|
+
// whole user manager restarts. `id ${me.user}` then shows the new
|
|
1018
|
+
// group while /proc/<pid>/status still shows the old one.
|
|
1019
|
+
fixMore: `systemctl restart user@${me.uid}.service # the unit alone keeps the old group set`,
|
|
1020
|
+
}
|
|
855
1021
|
: {}),
|
|
856
1022
|
});
|
|
857
1023
|
}
|
package/dist/journal.d.ts
CHANGED
|
@@ -20,6 +20,16 @@ export interface PendingMessage {
|
|
|
20
20
|
* session worktree is guaranteed to exist.
|
|
21
21
|
*/
|
|
22
22
|
attachments?: PendingAttachment[];
|
|
23
|
+
/**
|
|
24
|
+
* Feed seq of the `message` event this text was echoed under (ticket #125).
|
|
25
|
+
*
|
|
26
|
+
* The only identifier the queue and the browser share: the API never sees
|
|
27
|
+
* `id` (it is minted here), and the `session_message` frame carries no id of
|
|
28
|
+
* its own — so the seq of the bubble the user is looking at is what a recall
|
|
29
|
+
* has to name. Absent on records written by a runner older than 0.28.0:
|
|
30
|
+
* those cannot be recalled, which is correct — they predate the feature.
|
|
31
|
+
*/
|
|
32
|
+
seq?: number;
|
|
23
33
|
}
|
|
24
34
|
export interface PendingAttachment {
|
|
25
35
|
id: string;
|
|
@@ -43,6 +53,11 @@ export declare class SessionJournal {
|
|
|
43
53
|
extra?: Record<string, unknown>;
|
|
44
54
|
epoch?: number;
|
|
45
55
|
} | null;
|
|
56
|
+
/** The agent's conversation tip, and the provider session it lives in. */
|
|
57
|
+
lastAnchor: {
|
|
58
|
+
anchor: string;
|
|
59
|
+
providerSessionId: string;
|
|
60
|
+
} | null;
|
|
46
61
|
constructor(sessionId: string, dir?: string);
|
|
47
62
|
private replay;
|
|
48
63
|
private write;
|
|
@@ -60,6 +75,14 @@ export declare class SessionJournal {
|
|
|
60
75
|
* Statuses are fire-and-forget on the wire; journaling the latest one lets
|
|
61
76
|
* the supervisor re-report it after a reconnect (QA-96 F1).
|
|
62
77
|
*/
|
|
78
|
+
/**
|
|
79
|
+
* Remember the conversation tip across a process gap (ticket #126).
|
|
80
|
+
*
|
|
81
|
+
* Written only when it actually moves: an agent that says nothing writes
|
|
82
|
+
* nothing, and the same line repeated every turn would grow the file for no
|
|
83
|
+
* reason.
|
|
84
|
+
*/
|
|
85
|
+
recordAnchor(anchor: string, providerSessionId: string): void;
|
|
63
86
|
recordStatus(status: string, extra?: Record<string, unknown>, epoch?: number): void;
|
|
64
87
|
/** Assign the next seq and persist the event before it is sent. */
|
|
65
88
|
append(eventType: string, payload: Record<string, unknown>): JournalEvent;
|
|
@@ -77,9 +100,19 @@ export declare class SessionJournal {
|
|
|
77
100
|
* it is queued in memory, so the ordering is "on disk, then held" — a crash
|
|
78
101
|
* between the two costs a duplicate delivery at worst, never a lost message.
|
|
79
102
|
*/
|
|
80
|
-
appendPending(text: string, attachments?: PendingAttachment[]): PendingMessage;
|
|
103
|
+
appendPending(text: string, attachments?: PendingAttachment[], seq?: number): PendingMessage;
|
|
81
104
|
/** The message reached an agent — stop replaying it after a restart. */
|
|
82
105
|
resolvePending(id: string): void;
|
|
106
|
+
/**
|
|
107
|
+
* The user recalled the message before any agent saw it (ticket #125).
|
|
108
|
+
*
|
|
109
|
+
* Returns false when the record is already gone — which is the answer to
|
|
110
|
+
* "did I win the race", and the only safe way to ask it: the caller must
|
|
111
|
+
* report `already_delivered` rather than a success it cannot back up.
|
|
112
|
+
*/
|
|
113
|
+
cancelPending(id: string): boolean;
|
|
114
|
+
/** The queued record echoed under this feed seq, if it is still queued. */
|
|
115
|
+
findPendingBySeq(seq: number): PendingMessage | undefined;
|
|
83
116
|
/** Messages still waiting, oldest first (ids are minted in order). */
|
|
84
117
|
pending(): PendingMessage[];
|
|
85
118
|
get lastAssignedSeq(): number;
|
package/dist/journal.js
CHANGED
|
@@ -19,6 +19,8 @@ export class SessionJournal {
|
|
|
19
19
|
bytesOnDisk = 0;
|
|
20
20
|
/** Last status reported for this session — replayed after a reconnect. */
|
|
21
21
|
lastStatus = null;
|
|
22
|
+
/** The agent's conversation tip, and the provider session it lives in. */
|
|
23
|
+
lastAnchor = null;
|
|
22
24
|
constructor(sessionId, dir = journalDir()) {
|
|
23
25
|
this.sessionId = sessionId;
|
|
24
26
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -52,6 +54,9 @@ export class SessionJournal {
|
|
|
52
54
|
else if (parsed.kind === 'ack') {
|
|
53
55
|
this.unackedBySeq.delete(parsed.seq);
|
|
54
56
|
}
|
|
57
|
+
else if (parsed.kind === 'anchor') {
|
|
58
|
+
this.lastAnchor = { anchor: parsed.anchor, providerSessionId: parsed.providerSessionId };
|
|
59
|
+
}
|
|
55
60
|
else if (parsed.kind === 'status') {
|
|
56
61
|
this.lastStatus = {
|
|
57
62
|
status: parsed.status,
|
|
@@ -64,6 +69,7 @@ export class SessionJournal {
|
|
|
64
69
|
id: parsed.id,
|
|
65
70
|
text: parsed.text,
|
|
66
71
|
...(parsed.attachments?.length ? { attachments: parsed.attachments } : {}),
|
|
72
|
+
...(typeof parsed.seq === 'number' ? { seq: parsed.seq } : {}),
|
|
67
73
|
});
|
|
68
74
|
// Ids are `p<n>`; keep the counter above whatever the file holds so a
|
|
69
75
|
// restarted runner cannot mint an id that is already in flight.
|
|
@@ -71,7 +77,7 @@ export class SessionJournal {
|
|
|
71
77
|
if (Number.isFinite(n) && n >= this.pendingCounter)
|
|
72
78
|
this.pendingCounter = n + 1;
|
|
73
79
|
}
|
|
74
|
-
else if (parsed.kind === 'pending_done') {
|
|
80
|
+
else if (parsed.kind === 'pending_done' || parsed.kind === 'pending_cancelled') {
|
|
75
81
|
this.pendingById.delete(parsed.id);
|
|
76
82
|
}
|
|
77
83
|
else if (parsed.kind === 'seq') {
|
|
@@ -98,6 +104,9 @@ export class SessionJournal {
|
|
|
98
104
|
for (const event of this.unacked()) {
|
|
99
105
|
lines.push({ kind: 'event', ...event, ts: new Date().toISOString() });
|
|
100
106
|
}
|
|
107
|
+
if (this.lastAnchor) {
|
|
108
|
+
lines.push({ kind: 'anchor', ...this.lastAnchor });
|
|
109
|
+
}
|
|
101
110
|
if (this.lastStatus) {
|
|
102
111
|
lines.push({
|
|
103
112
|
kind: 'status',
|
|
@@ -115,6 +124,7 @@ export class SessionJournal {
|
|
|
115
124
|
id: record.id,
|
|
116
125
|
text: record.text,
|
|
117
126
|
...(record.attachments?.length ? { attachments: record.attachments } : {}),
|
|
127
|
+
...(record.seq === undefined ? {} : { seq: record.seq }),
|
|
118
128
|
ts: new Date().toISOString(),
|
|
119
129
|
});
|
|
120
130
|
}
|
|
@@ -138,6 +148,21 @@ export class SessionJournal {
|
|
|
138
148
|
* Statuses are fire-and-forget on the wire; journaling the latest one lets
|
|
139
149
|
* the supervisor re-report it after a reconnect (QA-96 F1).
|
|
140
150
|
*/
|
|
151
|
+
/**
|
|
152
|
+
* Remember the conversation tip across a process gap (ticket #126).
|
|
153
|
+
*
|
|
154
|
+
* Written only when it actually moves: an agent that says nothing writes
|
|
155
|
+
* nothing, and the same line repeated every turn would grow the file for no
|
|
156
|
+
* reason.
|
|
157
|
+
*/
|
|
158
|
+
recordAnchor(anchor, providerSessionId) {
|
|
159
|
+
if (this.lastAnchor?.anchor === anchor &&
|
|
160
|
+
this.lastAnchor.providerSessionId === providerSessionId) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.lastAnchor = { anchor, providerSessionId };
|
|
164
|
+
this.write({ kind: 'anchor', anchor, providerSessionId });
|
|
165
|
+
}
|
|
141
166
|
recordStatus(status, extra, epoch) {
|
|
142
167
|
this.lastStatus = {
|
|
143
168
|
status,
|
|
@@ -187,18 +212,20 @@ export class SessionJournal {
|
|
|
187
212
|
* it is queued in memory, so the ordering is "on disk, then held" — a crash
|
|
188
213
|
* between the two costs a duplicate delivery at worst, never a lost message.
|
|
189
214
|
*/
|
|
190
|
-
appendPending(text, attachments) {
|
|
215
|
+
appendPending(text, attachments, seq) {
|
|
191
216
|
const id = `p${this.pendingCounter++}`;
|
|
192
217
|
const record = {
|
|
193
218
|
id,
|
|
194
219
|
text,
|
|
195
220
|
...(attachments?.length ? { attachments } : {}),
|
|
221
|
+
...(seq === undefined ? {} : { seq }),
|
|
196
222
|
};
|
|
197
223
|
this.write({
|
|
198
224
|
kind: 'pending',
|
|
199
225
|
id,
|
|
200
226
|
text,
|
|
201
227
|
...(attachments?.length ? { attachments } : {}),
|
|
228
|
+
...(seq === undefined ? {} : { seq }),
|
|
202
229
|
ts: new Date().toISOString(),
|
|
203
230
|
});
|
|
204
231
|
this.pendingById.set(id, record);
|
|
@@ -211,6 +238,28 @@ export class SessionJournal {
|
|
|
211
238
|
this.pendingById.delete(id);
|
|
212
239
|
this.write({ kind: 'pending_done', id });
|
|
213
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The user recalled the message before any agent saw it (ticket #125).
|
|
243
|
+
*
|
|
244
|
+
* Returns false when the record is already gone — which is the answer to
|
|
245
|
+
* "did I win the race", and the only safe way to ask it: the caller must
|
|
246
|
+
* report `already_delivered` rather than a success it cannot back up.
|
|
247
|
+
*/
|
|
248
|
+
cancelPending(id) {
|
|
249
|
+
if (!this.pendingById.has(id))
|
|
250
|
+
return false;
|
|
251
|
+
this.pendingById.delete(id);
|
|
252
|
+
this.write({ kind: 'pending_cancelled', id });
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
/** The queued record echoed under this feed seq, if it is still queued. */
|
|
256
|
+
findPendingBySeq(seq) {
|
|
257
|
+
for (const record of this.pendingById.values()) {
|
|
258
|
+
if (record.seq === seq)
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
214
263
|
/** Messages still waiting, oldest first (ids are minted in order). */
|
|
215
264
|
pending() {
|
|
216
265
|
return [...this.pendingById.values()];
|
package/dist/paths.d.ts
CHANGED
|
@@ -22,6 +22,16 @@ export declare function worktreesDir(): string;
|
|
|
22
22
|
export declare function knownWorkspacesPath(): string;
|
|
23
23
|
/** Session 14: the single preview checkout per repository. */
|
|
24
24
|
export declare function previewsDir(): string;
|
|
25
|
+
/**
|
|
26
|
+
* Ticket #126: git object stores holding the sessions' restore points.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately under `devbridge-runner/`, and deliberately NOT under
|
|
29
|
+
* `worktrees/` or `previews/` — those two are excluded from the runner-tree
|
|
30
|
+
* rule in `SECRET_PATH_PATTERNS` (the agent has to be able to read its own
|
|
31
|
+
* workspace), so anything that must stay unreadable to the agent belongs
|
|
32
|
+
* exactly here.
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkpointsDir(): string;
|
|
25
35
|
/**
|
|
26
36
|
* Per-session MCP config files (ticket #119).
|
|
27
37
|
*
|