borgmcp 2.5.0 → 2.6.1

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.
Files changed (87) hide show
  1. package/README.md +12 -7
  2. package/dist/assimilate-cmd.d.ts +1 -0
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +58 -30
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +2 -0
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/claude.js +9 -3
  10. package/dist/claude.js.map +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.d.ts.map +1 -1
  13. package/dist/cli-help.js +7 -4
  14. package/dist/cli-help.js.map +1 -1
  15. package/dist/codex-launch.d.ts +2 -2
  16. package/dist/codex-launch.d.ts.map +1 -1
  17. package/dist/codex-launch.js +7 -7
  18. package/dist/codex-launch.js.map +1 -1
  19. package/dist/first-run-server.d.ts +31 -0
  20. package/dist/first-run-server.d.ts.map +1 -0
  21. package/dist/first-run-server.js +117 -0
  22. package/dist/first-run-server.js.map +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +25 -27
  25. package/dist/index.js.map +1 -1
  26. package/dist/log-stream.d.ts +4 -8
  27. package/dist/log-stream.d.ts.map +1 -1
  28. package/dist/log-stream.js +22 -15
  29. package/dist/log-stream.js.map +1 -1
  30. package/dist/opencode-drone.d.ts +11 -5
  31. package/dist/opencode-drone.d.ts.map +1 -1
  32. package/dist/opencode-drone.js +236 -48
  33. package/dist/opencode-drone.js.map +1 -1
  34. package/dist/opencode-wake-copy.d.ts +2 -0
  35. package/dist/opencode-wake-copy.d.ts.map +1 -0
  36. package/dist/opencode-wake-copy.js +8 -0
  37. package/dist/opencode-wake-copy.js.map +1 -0
  38. package/dist/regen-format.d.ts +4 -6
  39. package/dist/regen-format.d.ts.map +1 -1
  40. package/dist/regen-format.js +10 -14
  41. package/dist/regen-format.js.map +1 -1
  42. package/dist/repository-cube-init.d.ts +2 -0
  43. package/dist/repository-cube-init.d.ts.map +1 -1
  44. package/dist/repository-cube-init.js +14 -3
  45. package/dist/repository-cube-init.js.map +1 -1
  46. package/dist/server-facade.d.ts +2 -0
  47. package/dist/server-facade.d.ts.map +1 -1
  48. package/dist/server-facade.js +25 -3
  49. package/dist/server-facade.js.map +1 -1
  50. package/dist/setup.js +10 -0
  51. package/dist/setup.js.map +1 -1
  52. package/dist/stream-status.d.ts +9 -8
  53. package/dist/stream-status.d.ts.map +1 -1
  54. package/dist/stream-status.js +68 -18
  55. package/dist/stream-status.js.map +1 -1
  56. package/dist/tool-manifest.js +2 -2
  57. package/dist/tool-manifest.js.map +1 -1
  58. package/dist/update-cmd.d.ts +11 -1
  59. package/dist/update-cmd.d.ts.map +1 -1
  60. package/dist/update-cmd.js +68 -4
  61. package/dist/update-cmd.js.map +1 -1
  62. package/dist/wake-path-health.d.ts +27 -0
  63. package/dist/wake-path-health.d.ts.map +1 -0
  64. package/dist/wake-path-health.js +51 -0
  65. package/dist/wake-path-health.js.map +1 -0
  66. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  67. package/docs/LOCAL_SERVER.md +12 -3
  68. package/docs/RELEASING.md +11 -1
  69. package/package.json +1 -1
  70. package/src/assimilate-cmd.ts +66 -27
  71. package/src/assimilate-deps.ts +9 -0
  72. package/src/claude.ts +10 -3
  73. package/src/cli-help.ts +7 -4
  74. package/src/codex-launch.ts +7 -7
  75. package/src/first-run-server.ts +178 -0
  76. package/src/index.ts +24 -28
  77. package/src/log-stream.ts +36 -25
  78. package/src/opencode-drone.ts +283 -47
  79. package/src/opencode-wake-copy.ts +8 -0
  80. package/src/regen-format.ts +10 -16
  81. package/src/repository-cube-init.ts +16 -3
  82. package/src/server-facade.ts +34 -3
  83. package/src/setup.ts +11 -0
  84. package/src/stream-status.ts +86 -22
  85. package/src/tool-manifest.ts +2 -2
  86. package/src/update-cmd.ts +88 -5
  87. package/src/wake-path-health.ts +85 -0
@@ -18,6 +18,7 @@ import {
18
18
  } from './roster-render.js';
19
19
  import { shellEscape } from './shell-escape.js';
20
20
  import { resolveInboxMonitorPath } from './self-path.js';
21
+ import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
21
22
 
22
23
  /**
23
24
  * Extract the SessionStart `source` from a Claude Code hook payload (gh#926).
@@ -26,9 +27,8 @@ import { resolveInboxMonitorPath } from './self-path.js';
26
27
  * one of `startup` / `resume` / `clear` / `compact`. The `borg-regen`
27
28
  * SessionStart hook uses this to detect a `/clear` re-orientation, which is
28
29
  * the FIRST time the hook is the SOLE orientation path (the launch kickoff
29
- * prompt is gone) AND the moment Claude Code clears the session-scoped
30
- * `/loop` + `ScheduleWakeup` — so the re-injected orientation must instruct
31
- * an operational re-arm.
30
+ * prompt is gone), so the re-injected orientation must instruct an operational
31
+ * Monitor re-arm.
32
32
  *
33
33
  * Best-effort + total: empty input (manual / TTY run with no stdin),
34
34
  * malformed JSON, a missing `source`, or a non-string `source` all return
@@ -59,9 +59,8 @@ export type AgentKind = 'claude' | 'codex' | 'opencode';
59
59
  *
60
60
  * Agent-branched on the existing env-agnostic signal (BORG_SESSION-style
61
61
  * `isCodexRemoteWakeEnabled`), NOT on a mutable server-recorded field:
62
- * - claude: arm the inbox-file tail Monitor + engage `/loop` + maintain one
63
- * adaptive `ScheduleWakeup` recovery deadline (long while the Monitor is
64
- * healthy or indeterminate; short only while explicitly broken).
62
+ * - claude: arm the inbox-file tail Monitor, drain unread entries on every
63
+ * wake, and re-arm the Monitor if it exits or is missing.
65
64
  * - codex: Borg's activity stream reaches the app-server remote-control inbox
66
65
  * channel; each wake is followed by an unread-log drain. Manual full regen
67
66
  * + drain is a degraded fallback when remote control is unavailable.
@@ -85,11 +84,7 @@ export function wakePathArming(
85
84
  ].join(' ');
86
85
  }
87
86
  if (agentKind === 'opencode') {
88
- return (
89
- 'Wake path: OpenCode wakes via SDK-driven entry injection into the TUI session — ' +
90
- 'there is no tail-Monitor or `/loop` heartbeat to arm. Cube posts are delivered ' +
91
- 'directly into your active session. Use \`borg_regen\` at any time for fresh context.'
92
- );
87
+ return OPENCODE_WAKE_PATH_GUIDANCE;
93
88
  }
94
89
  // gh#client#18: use absolute path to THIS installation's borg-inbox-monitor
95
90
  // so the orientation command always resolves to the same version as the
@@ -101,9 +96,8 @@ export function wakePathArming(
101
96
  return [
102
97
  'Arm your wake path before working:',
103
98
  `1. **Inbox Monitor** (wake path) — run a persistent Monitor on \`${monitorCommand}\` so cube posts wake you in real time.`,
104
- '2. **Engage `/loop`** (self-paced) so you keep waking to triage the cube.',
105
- '3. **Adaptive recovery** — set ONE `ScheduleWakeup`: [9000, 12600]s (3h ±30m) when Monitor healthy/indeterminate; [720, 1080]s (15m ±3m) when broken. Re-arm Monitor; retry short until healthy. A real Monitor wake resets, not stacks.',
106
- '4. **Recovery tick** — drain `borg_read-log unread_only=true`. If empty, check wake status, set deadline, resume prior work. No full-regen or liveness post on empty; safety probes may still wake.',
99
+ '2. **On every wake** — drain `borg_read-log unread_only=true`. If empty, resume prior work without a full regen or liveness post; safety probes may still wake.',
100
+ '3. **Monitor recovery** — if the Monitor exits or is missing, re-arm it when the exit notification wakes you.',
107
101
  ].join('\n');
108
102
  }
109
103
 
@@ -168,8 +162,8 @@ export function formatLeanOrientation(args: {
168
162
  ? '\n_(`/clear` cleared your conversation; Codex remote-control wake remains active. Follow the required Codex wake path below.)_\n'
169
163
  : agentKind === 'claude'
170
164
  ? [
171
- '\n_(`/clear` cleared Claude\'s `/loop` and recovery deadline — re-arm them now.)_',
172
- '_Quiet-clear fallback: if a later turn follows silence, inspect `borg_stream-status` + `borg_roster`; call `borg_regen mode="full"`, drain `borg_read-log unread_only=true`, then re-arm Monitor, `/loop`, and the recovery deadline._\n',
165
+ '\n_(`/clear` cleared Claude\'s conversation — re-arm the inbox Monitor now.)_',
166
+ '_Quiet-clear fallback: if a later turn follows silence, inspect `borg_stream-status` + `borg_roster`; call `borg_regen mode="full"`, drain `borg_read-log unread_only=true`, then re-arm the Monitor._\n',
173
167
  ].join('\n')
174
168
  : ''
175
169
  : '';
@@ -46,6 +46,8 @@ export interface RepositoryCubeInitDeps {
46
46
  isTTY(): boolean;
47
47
  prompt(message: string): Promise<string>;
48
48
  write(text: string): void;
49
+ writeResult?(text: string): void;
50
+ useColor?(): boolean;
49
51
  getIdentity(context: GitRepositoryContext): Promise<CreateCubeRepository>;
50
52
  getAssociation(repository: CreateCubeRepository): Promise<RepositoryAssociation | null>;
51
53
  saveAssociation(repository: CreateCubeRepository, association: RepositoryAssociation): Promise<void>;
@@ -132,8 +134,16 @@ function renderResult(
132
134
  adopted?: boolean;
133
135
  },
134
136
  ): void {
137
+ const resultWriter = deps.writeResult ?? deps.write;
138
+ const heading = input.adopted
139
+ ? 'Existing cube associated with this repository.'
140
+ : input.existing
141
+ ? 'Cube already initialized.'
142
+ : 'Cube created.';
135
143
  const lines = [
136
- input.adopted ? 'Existing cube associated with this repository.' : input.existing ? 'Cube already initialized.' : 'Cube created.',
144
+ input.mode === 'cube-init'
145
+ ? `${deps.useColor?.() ? '\u001b[32m✓\u001b[0m' : '✓'} ${heading}`
146
+ : heading,
137
147
  ` Name: ${input.response.name}`,
138
148
  ` Template: ${presentation(input.response.template).label}`,
139
149
  ` Repository: ${input.root}`,
@@ -150,7 +160,7 @@ function renderResult(
150
160
  } else {
151
161
  lines.push('Continuing with role and seat setup...');
152
162
  }
153
- deps.write(`${lines.join('\n')}\n`);
163
+ resultWriter(`${lines.join('\n')}\n`);
154
164
  }
155
165
 
156
166
  export async function initializeRepositoryCube(input: {
@@ -300,8 +310,11 @@ export async function initializeRepositoryCube(input: {
300
310
  if (proposedAdoption) return proposedAdoption;
301
311
 
302
312
  if (input.canCreate === false) {
313
+ const retryCommand = input.mode === 'cube-init'
314
+ ? `borg server cube init --host ${shellEscape(input.serverOrigin)}`
315
+ : `borg assimilate --host ${shellEscape(input.serverOrigin)}`;
303
316
  deps.write(
304
- `This enrolled client cannot create a cube on ${input.serverOrigin}. Ask the server operator to grant access to a cube, then rerun borg assimilate --host ${shellEscape(input.serverOrigin)}.\n`,
317
+ `This enrolled client cannot create a cube on ${input.serverOrigin}. Ask the server operator to grant access to a cube, then rerun ${retryCommand}.\n`,
305
318
  );
306
319
  return { kind: 'stop', code: 1 };
307
320
  }
@@ -1,6 +1,9 @@
1
1
  import { spawn as spawnChild, type SpawnOptions } from 'node:child_process';
2
2
  import { constants } from 'node:os';
3
+ import chalk from 'chalk';
3
4
  import { cubeInitHelpText, isHelpFlag, serverHelpText } from './cli-help.js';
5
+ import { consolePrefix } from './console-prefix.js';
6
+ import { getPackageVersion } from './version.js';
4
7
 
5
8
  export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite', 'dashboard'] as const;
6
9
  export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
@@ -12,6 +15,10 @@ export type ParsedServerFacadeArgs =
12
15
  | { kind: 'command'; command: ServerLifecycleCommand; args: string[] }
13
16
  | { kind: 'error'; reason: 'unknown-command'; command: string };
14
17
 
18
+ export function isClientOwnedCubeInitArgv(argv: readonly string[]): boolean {
19
+ return argv[2] === 'server' && argv[3] === 'cube' && argv[4] === 'init';
20
+ }
21
+
15
22
  export function parseServerFacadeArgs(args: readonly string[]): ParsedServerFacadeArgs {
16
23
  const [command, ...rest] = args;
17
24
  if (command === undefined || command === '--help' || command === '-h') {
@@ -92,8 +99,9 @@ export function buildDefaultServerFacadeClientDeps(
92
99
  import('./assimilate-cmd.js'),
93
100
  ]);
94
101
  const parsed = parseAssimilateArgs([...args]);
95
- if (!parsed.ok || parsed.role !== undefined) {
96
- process.stderr.write(`${parsed.ok ? 'borg server cube init does not accept a role' : parsed.error}\n`);
102
+ const unsupported = parsed.ok ? unsupportedCubeInitInput(args, parsed) : undefined;
103
+ if (!parsed.ok || unsupported !== undefined) {
104
+ process.stderr.write(cubeInitUsageErrorText(parsed.ok ? unsupported! : parsed.error));
97
105
  return 1;
98
106
  }
99
107
  return runAssimilate(
@@ -104,6 +112,29 @@ export function buildDefaultServerFacadeClientDeps(
104
112
  };
105
113
  }
106
114
 
115
+ function unsupportedCubeInitInput(
116
+ args: readonly string[],
117
+ parsed: { role: string | undefined },
118
+ ): string | undefined {
119
+ if (parsed.role !== undefined) {
120
+ return 'A role is not accepted because this command does not create a drone.';
121
+ }
122
+ const unsupported = ['--worktree', '--here', '--force', '--cli', '--model', '--backend', '--no-template'];
123
+ const flag = args.find((arg) => unsupported.some((candidate) =>
124
+ arg === candidate || arg.startsWith(`${candidate}=`)));
125
+ return flag === undefined
126
+ ? undefined
127
+ : `${flag.split('=', 1)[0]} is not accepted because this command does not create a drone.`;
128
+ }
129
+
130
+ export function cubeInitUsageErrorText(reason: string): string {
131
+ const sentence = /[.!?]$/.test(reason) ? reason : `${reason}.`;
132
+ return (
133
+ chalk.red(`${consolePrefix()}◼ borg server cube init: ${sentence}\n`) +
134
+ 'Run `borg server cube init --help` for usage.\n'
135
+ );
136
+ }
137
+
107
138
  const defaultClientDeps = buildDefaultServerFacadeClientDeps();
108
139
 
109
140
  const MAX_RENDERED_COMMAND_CODE_POINTS = 80;
@@ -222,7 +253,7 @@ export async function runEarlyServerFacade(
222
253
  return 0;
223
254
  }
224
255
  if (parsed.kind === 'cube-init-help') {
225
- output.writeStdout(cubeInitHelpText());
256
+ output.writeStdout(cubeInitHelpText(getPackageVersion()));
226
257
  return 0;
227
258
  }
228
259
  if (parsed.kind === 'error') {
package/src/setup.ts CHANGED
@@ -37,6 +37,7 @@ import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
37
37
  import { handleVersionFlag } from './version.js';
38
38
  import { initDebugFromArgv } from './debug.js';
39
39
  import { defaultApprovalIo, setupApprovalWarnings } from './cli-tool-approval.js';
40
+ import { offerFirstRunServerInstall } from './first-run-server.js';
40
41
 
41
42
  /**
42
43
  * Main setup wizard
@@ -67,6 +68,16 @@ async function main() {
67
68
  process.exit(1);
68
69
  }
69
70
 
71
+ // Resolve the separately published local server before setup writes agent
72
+ // configuration. Decline/non-interactive/failure paths therefore leave no
73
+ // partial setup state behind.
74
+ console.log(chalk.blue('◼ Local Server'));
75
+ const serverInstall = await offerFirstRunServerInstall();
76
+ if (serverInstall.kind !== 'present' && serverInstall.kind !== 'installed') {
77
+ process.exit(serverInstall.kind === 'declined' ? 0 : 1);
78
+ }
79
+ console.log('');
80
+
70
81
  // Step 1: Configure every detected agent CLI
71
82
  console.log(chalk.blue('◼ Agent CLI Integration'));
72
83
 
@@ -28,6 +28,7 @@ import {
28
28
  HEARTBEAT_STALE_MS,
29
29
  } from './inbox-monitor.js';
30
30
  import { shellEscape } from './shell-escape.js';
31
+ import type { WakePathSnapshot } from './wake-path-health.js';
31
32
 
32
33
  /**
33
34
  * Best-effort check: is a process tailing this inbox file?
@@ -111,11 +112,10 @@ export function isHeartbeatStale(inboxPath: string, monitorStateRoot?: string |
111
112
 
112
113
  export interface RenderInputs {
113
114
  status: StreamStatus;
114
- /**
115
- * Tri-state Monitor liveness: true = healthy, false = wake-path
116
- * broken, null = cannot determine.
117
- */
115
+ /** Legacy Claude Monitor health; runtime-aware callers also pass `wakePath`. */
118
116
  inboxMonitorHealthy: boolean | null;
117
+ /** Runtime-specific wake-path evidence. Omitted by legacy pure-render callers. */
118
+ wakePath?: WakePathSnapshot;
119
119
  /**
120
120
  * Inbox path for the State-5 self-arm instruction. Pass null when
121
121
  * unknown (no active cube); State 5 will then surface the failure
@@ -140,6 +140,12 @@ export interface RenderInputs {
140
140
  export function renderStreamStatus(inputs: RenderInputs): string {
141
141
  const { status, inboxMonitorHealthy, inboxPath, monitorStateRoot, droneLabel, cubeName, humanAgo } =
142
142
  inputs;
143
+ const wakePath = inputs.wakePath ?? {
144
+ agentKind: 'claude' as const,
145
+ healthy: inboxMonitorHealthy,
146
+ openCode: null,
147
+ };
148
+ const wakePathHealthy = wakePath.healthy;
143
149
 
144
150
  const isNotStarted =
145
151
  status.reconnectAttempts === 0 &&
@@ -149,9 +155,8 @@ export function renderStreamStatus(inputs: RenderInputs): string {
149
155
  const orphanedInitialization = status.ownership?.state === 'orphaned-initialization';
150
156
  const ownershipInitializing = status.ownership?.state === 'initializing';
151
157
 
152
- // Top-line verdict — 5 states + override per drone-4 contract.
153
- // Precedence: disconnected > no-inbox-Monitor (wire-down upstream
154
- // cause; State 5 only applies when wire is healthy).
158
+ // Top-line verdict — 5 states + runtime wake-path override.
159
+ // Precedence: disconnected > wake-path failure.
155
160
  let summary: string;
156
161
  if (orphanedInitialization) {
157
162
  summary = '**Stream blocked by an orphaned initialization lock.**';
@@ -161,8 +166,12 @@ export function renderStreamStatus(inputs: RenderInputs): string {
161
166
  summary = '**Stream not started.**';
162
167
  } else if (!status.connected) {
163
168
  summary = `**Stream disconnected (reconnect attempt ${status.reconnectAttempts}).**`;
164
- } else if (inboxMonitorHealthy === false) {
165
- summary = '**Stream connected (no inbox-Monitor — wake path broken).**';
169
+ } else if (wakePathHealthy === false) {
170
+ summary = wakePath.agentKind === 'opencode'
171
+ ? '**Stream connected (OpenCode delivery degraded).**'
172
+ : wakePath.agentKind === 'codex'
173
+ ? '**Stream connected (Codex wake path unavailable).**'
174
+ : '**Stream connected (no inbox-Monitor — wake path broken).**';
166
175
  } else if (status.lastContentEventAt === null) {
167
176
  // State 2: wire works, no content yet. Collapses two underlying
168
177
  // conditions per drone-4 contract — fresh connect pre-first-content
@@ -247,13 +256,42 @@ export function renderStreamStatus(inputs: RenderInputs): string {
247
256
  );
248
257
  }
249
258
 
250
- // State-5 body line + self-arm instruction. Only fires when we
251
- // POSITIVELY detected wake-path breakage AND the wire is up — the
252
- // disconnected case takes precedence above and would never reach
253
- // here. When `inboxMonitorHealthy === null` (couldn't determine) we
254
- // stay silent; surfacing an uncertain failure mode is worse UX than
255
- // omitting it.
256
- if (status.connected && inboxMonitorHealthy === false) {
259
+ if (wakePath.agentKind === 'opencode' && wakePath.openCode) {
260
+ const delivery = wakePath.openCode.deliveryStates;
261
+ lines.push(`- **OpenCode delivery connected**: ${wakePath.openCode.connected}`);
262
+ lines.push(`- **OpenCode queued**: ${delivery.queued}`);
263
+ lines.push(
264
+ `- **OpenCode delivered-unconfirmed**: ${delivery['delivered-unconfirmed']}`
265
+ );
266
+ lines.push(`- **OpenCode retried**: ${delivery.retried}`);
267
+ lines.push(`- **OpenCode failed**: ${delivery.failed}`);
268
+ }
269
+
270
+ // Runtime-specific wake-path warning. The wire-down case takes
271
+ // precedence above; an indeterminate signal remains honest and silent.
272
+ if (status.connected && wakePathHealthy === false) {
273
+ if (wakePath.agentKind === 'opencode') {
274
+ lines.push(
275
+ '- **OpenCode delivery**: _(degraded — inspect the durable unread log)_'
276
+ );
277
+ lines.push('');
278
+ lines.push('## OpenCode wake delivery is degraded');
279
+ lines.push(
280
+ 'One or more durable inbox entries were rejected or could not be confirmed. Run `borg_read-log unread_only=true` and drain the unread log now. Use `borg_stream-status` to check whether failed or delivered-unconfirmed entries remain.'
281
+ );
282
+ return lines.join('\n');
283
+ }
284
+ if (wakePath.agentKind === 'codex') {
285
+ lines.push(
286
+ '- **Codex wake path**: _(remote-control bridge unavailable)_'
287
+ );
288
+ lines.push('');
289
+ lines.push('## Codex wake delivery is unavailable');
290
+ lines.push(
291
+ 'The Codex remote-control bridge cannot currently deliver a wake. Run `borg_read-log unread_only=true` and drain the unread log now, then reconnect the Codex wake path.'
292
+ );
293
+ return lines.join('\n');
294
+ }
257
295
  lines.push(
258
296
  `- **inbox-monitor**: _(no watcher detected — wake path broken)_`
259
297
  );
@@ -278,13 +316,13 @@ export function renderStreamStatus(inputs: RenderInputs): string {
278
316
  * from the inline ternary in `src/index.ts` for direct unit-test
279
317
  * coverage of the (connected × healthy) cross-product).
280
318
  *
281
- * Returns true ONLY when the wire is up AND we positively detected a
282
- * dead inbox Monitor (`=== false` strict). The `null` branch
319
+ * Returns true ONLY when the wire is up AND the runtime-specific wake
320
+ * mechanism is positively unhealthy (`=== false` strict). The `null` branch
283
321
  * (couldn't determine) stays silent — surfacing an uncertain failure
284
322
  * mode is worse UX than omitting it (mirrors the State-5 precedence
285
323
  * rule in `renderStreamStatus`). When disconnected, the wire-down case
286
324
  * is the upstream cause and takes precedence; no point warning about
287
- * the wake path when the wake-path's input has no events to deliver.
325
+ * the wake path when its input has no events to deliver.
288
326
  */
289
327
  export function shouldShowWakePathWarning(
290
328
  streamStatus: StreamStatus,
@@ -294,7 +332,7 @@ export function shouldShowWakePathWarning(
294
332
  }
295
333
 
296
334
  /**
297
- * Wake-path-broken prefix for `borg_regen` output (gh#43).
335
+ * Runtime-specific wake-path-broken prefix for `borg_regen` output.
298
336
  *
299
337
  * Pure function — caller decides whether to call (gates on
300
338
  * `shouldShowWakePathWarning`). Returns an empty string when called
@@ -315,13 +353,39 @@ export function formatWakePathPrefix(inputs: {
315
353
  monitorStateRoot?: string | null;
316
354
  droneLabel: string | null;
317
355
  cubeName: string | null;
356
+ wakePath?: WakePathSnapshot;
318
357
  }): string {
319
- const { inboxPath, monitorStateRoot, droneLabel, cubeName } = inputs;
358
+ const { inboxPath, monitorStateRoot, droneLabel, cubeName, wakePath } = inputs;
359
+ if (wakePath?.agentKind === 'opencode') {
360
+ const delivery = wakePath.openCode?.deliveryStates;
361
+ return [
362
+ '## ⚠ OpenCode wake delivery degraded',
363
+ '',
364
+ `Durable delivery has ${
365
+ delivery
366
+ ? `${delivery['delivered-unconfirmed']} delivered-unconfirmed and ${delivery.failed} failed entries`
367
+ : 'an unhealthy runtime signal'
368
+ }. Run \`borg_stream-status\`, then run \`borg_read-log unread_only=true\` and drain the unread log.`,
369
+ '',
370
+ '---',
371
+ '',
372
+ ].join('\n');
373
+ }
374
+ if (wakePath?.agentKind === 'codex') {
375
+ return [
376
+ '## ⚠ Codex wake path unavailable',
377
+ '',
378
+ 'The remote-control bridge cannot currently deliver a wake. Run `borg_read-log unread_only=true` and drain the unread log, then reconnect the Codex wake path.',
379
+ '',
380
+ '---',
381
+ '',
382
+ ].join('\n');
383
+ }
320
384
  if (!inboxPath || !droneLabel || !cubeName) return '';
321
385
  return [
322
386
  `## ⚠ Wake path broken — arm Monitor NOW`,
323
387
  ``,
324
- `No process is tailing this drone's inbox file. SSE delivery is healthy (entries reach disk), but Claude Code has no event source to wake on. Until you arm a Monitor, this session only wakes on the /loop fallback heartbeat and will miss live coordination from other drones:`,
388
+ `No process is tailing this drone's inbox file. SSE delivery is healthy (entries reach disk), but Claude Code has no event source to wake on. Until you arm a Monitor, this session has no wake path and will miss live coordination from other drones:`,
325
389
  ``,
326
390
  `> Monitor command: \`${monitorCommand(inboxPath, monitorStateRoot)}\` — persistent, 1h timeout, description "borg inbox for ${droneLabel} on cube ${cubeName}".`,
327
391
  ``,
@@ -172,7 +172,7 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
172
172
  {
173
173
  name: 'borg_roster',
174
174
  description:
175
- "List all currently connected drones in your cube, with each drone's label, role, and last-seen time. Optional `since` argument adds a sender-side liveness column — pass either an activity_log entry id (e.g., from a dispatch you posted) or an ISO-8601 timestamp; each drone is marked `awake` if they've posted a log entry after that point, otherwise `stale-since-X`. Useful for confirming a dispatch reached its named recipients (catches the silent-wake-path-failure class where SSE delivered but the drone's /loop never woke).",
175
+ "List all currently connected drones in your cube, with each drone's label, role, and last-seen time. Optional `since` argument adds a sender-side liveness column — pass either an activity_log entry id (e.g., from a dispatch you posted) or an ISO-8601 timestamp; each drone is marked `awake` if they've posted a log entry after that point, otherwise `stale-since-X`. Useful for confirming a dispatch reached its named recipients (catches the silent-wake-path-failure class where SSE delivered but the drone's inbox Monitor never woke it).",
176
176
  inputSchema: {
177
177
  type: 'object',
178
178
  properties: {
@@ -188,7 +188,7 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
188
188
  {
189
189
  name: 'borg_stream-status',
190
190
  description:
191
- "Diagnostic probe of the local SSE log-stream consumer: returns `connected`, `lastContentEventAt`, `lastWireActivityAt`, `lastHeartbeatAt`, `lastPersistedEventId`, `reconnectAttempts`, plus a wake-path check that flags if SSE is attached but no inbox-Monitor is watching the file (the silent failure where `/loop` never wakes on incoming entries). Read-only in-process state; does NOT re-open the stream. Use when troubleshooting wake-ups or verifying the stream is alive.",
191
+ "Diagnostic probe of the local SSE log-stream consumer: returns `connected`, `lastContentEventAt`, `lastWireActivityAt`, `lastHeartbeatAt`, `lastPersistedEventId`, `reconnectAttempts`, plus a wake-path check that flags if SSE is attached but no inbox Monitor is watching the file (the silent failure where incoming entries reach disk but do not wake the drone). Read-only in-process state; does NOT re-open the stream. Use when troubleshooting wake-ups or verifying the stream is alive.",
192
192
  inputSchema: {
193
193
  type: 'object',
194
194
  properties: {},
package/src/update-cmd.ts CHANGED
@@ -54,7 +54,12 @@ export interface UpdateDeps {
54
54
  name: typeof CLIENT_PACKAGE | typeof SERVER_PACKAGE,
55
55
  version: string,
56
56
  ): Promise<PublishedPackage>;
57
- installGlobal(name: typeof CLIENT_PACKAGE | typeof SERVER_PACKAGE, version: string): Promise<void>;
57
+ publishedVersions(name: typeof CLIENT_PACKAGE | typeof SERVER_PACKAGE): Promise<string[]>;
58
+ installGlobal(
59
+ name: typeof CLIENT_PACKAGE | typeof SERVER_PACKAGE,
60
+ version: string,
61
+ options?: { ignoreScripts?: boolean },
62
+ ): Promise<void>;
58
63
  reenter(binPath: string, args: readonly string[]): Promise<number>;
59
64
  serverJson(binPath: string, command: 'update' | 'status'): Promise<unknown>;
60
65
  verifyRunningProtocol(origin: string): Promise<void>;
@@ -150,7 +155,7 @@ function renderServerState(
150
155
  );
151
156
  }
152
157
 
153
- function isExactSemver(value: unknown): boolean {
158
+ export function isExactSemver(value: unknown): value is string {
154
159
  return typeof value === 'string' && EXACT_SEMVER.test(value);
155
160
  }
156
161
 
@@ -275,6 +280,44 @@ async function publishedPair(
275
280
  return { client, server };
276
281
  }
277
282
 
283
+ /**
284
+ * Resolve the exact published server that can run with an already-installed
285
+ * client. First-run onboarding uses this instead of inventing a second
286
+ * registry path or installing an unpinned `latest` spec.
287
+ */
288
+ export async function resolveCompatibleServerTarget(
289
+ clientSharedVersion: string,
290
+ deps: Pick<UpdateDeps, 'publishedPackage' | 'publishedVersions'>,
291
+ ): Promise<PublishedPackage> {
292
+ if (!isExactSemver(clientSharedVersion)) {
293
+ throw new Error(`installed client has an invalid ${SHARED_PACKAGE} pin`);
294
+ }
295
+ const stableVersions = (await deps.publishedVersions(SERVER_PACKAGE))
296
+ .filter((version) => isExactSemver(version) && !version.includes('-'))
297
+ .sort(compareStableSemverDescending);
298
+ for (const version of stableVersions) {
299
+ const server = await deps.publishedPackage(SERVER_PACKAGE, version);
300
+ validatePublishedPackage(server, SERVER_PACKAGE);
301
+ if (server.version !== version) {
302
+ throw new Error(`registry returned the wrong ${SERVER_PACKAGE} manifest version`);
303
+ }
304
+ if (server.sharedVersion === clientSharedVersion) return server;
305
+ }
306
+ throw new Error(
307
+ `no published ${SERVER_PACKAGE} release uses ${SHARED_PACKAGE}@${clientSharedVersion}`,
308
+ );
309
+ }
310
+
311
+ function compareStableSemverDescending(left: string, right: string): number {
312
+ const leftParts = left.split('+', 1)[0].split('.').map((part) => BigInt(part));
313
+ const rightParts = right.split('+', 1)[0].split('.').map((part) => BigInt(part));
314
+ for (let index = 0; index < 3; index += 1) {
315
+ if (leftParts[index] > rightParts[index]) return -1;
316
+ if (leftParts[index] < rightParts[index]) return 1;
317
+ }
318
+ return right.localeCompare(left);
319
+ }
320
+
278
321
  function assertInstalled(
279
322
  installed: InstalledPackage,
280
323
  published: PublishedPackage,
@@ -533,7 +576,11 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
533
576
  if (client.version !== pair.client.version || client.sharedVersion !== pair.client.sharedVersion) {
534
577
  let installedClient: InstalledPackage | null = null;
535
578
  try {
536
- await deps.installGlobal(CLIENT_PACKAGE, pair.client.version);
579
+ await deps.installGlobal(
580
+ CLIENT_PACKAGE,
581
+ pair.client.version,
582
+ { ignoreScripts: true },
583
+ );
537
584
  installedClient = await deps.currentClient();
538
585
  assertInstalled(installedClient, pair.client);
539
586
  const args = [
@@ -597,7 +644,11 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
597
644
  discoveredServer.version !== pair.server.version ||
598
645
  discoveredServer.sharedVersion !== pair.server.sharedVersion
599
646
  ) {
600
- await deps.installGlobal(SERVER_PACKAGE, pair.server.version);
647
+ await deps.installGlobal(
648
+ SERVER_PACKAGE,
649
+ pair.server.version,
650
+ { ignoreScripts: true },
651
+ );
601
652
  }
602
653
  const verified = await deps.currentServer();
603
654
  if (!verified) throw new Error('server controller disappeared after installation');
@@ -920,6 +971,36 @@ async function defaultPublishedPackage(
920
971
  return published;
921
972
  }
922
973
 
974
+ async function defaultPublishedVersions(
975
+ name: typeof CLIENT_PACKAGE | typeof SERVER_PACKAGE,
976
+ context: NpmContext,
977
+ ): Promise<string[]> {
978
+ void context;
979
+ const endpoint = new URL(encodeURIComponent(name), CANONICAL_NPM_REGISTRY);
980
+ try {
981
+ const response = await fetch(endpoint, {
982
+ headers: { Accept: 'application/json' },
983
+ redirect: 'error',
984
+ });
985
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
986
+ const parsed: unknown = await response.json();
987
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
988
+ throw new Error('response was not a package object');
989
+ }
990
+ const versions = (parsed as Record<string, unknown>).versions;
991
+ if (!versions || typeof versions !== 'object' || Array.isArray(versions)) {
992
+ throw new Error('package versions were not an object');
993
+ }
994
+ const values = Object.keys(versions);
995
+ if (values.length === 0 || values.some((version) => !isExactSemver(version))) {
996
+ throw new Error('package versions were missing or invalid');
997
+ }
998
+ return values;
999
+ } catch {
1000
+ throw new Error(`registry version lookup failed for ${name}`);
1001
+ }
1002
+ }
1003
+
923
1004
  async function defaultConfirm(message: string): Promise<'yes' | 'no' | 'eof' | 'interrupted'> {
924
1005
  const rl = createInterface({ input: process.stdin, output: process.stdout });
925
1006
  let interrupted = false;
@@ -958,11 +1039,13 @@ export function buildDefaultUpdateDeps(): UpdateDeps {
958
1039
  await context(),
959
1040
  ),
960
1041
  publishedPackage: async (name, version) => defaultPublishedPackage(name, version, await context()),
961
- installGlobal: async (name, version) => {
1042
+ publishedVersions: async (name) => defaultPublishedVersions(name, await context()),
1043
+ installGlobal: async (name, version, options) => {
962
1044
  const npm = await context();
963
1045
  const result = await runCommand(npm.commandPath, [
964
1046
  'install',
965
1047
  '--global',
1048
+ ...(options?.ignoreScripts ? ['--ignore-scripts'] : []),
966
1049
  `--prefix=${npm.prefix}`,
967
1050
  `--registry=${CANONICAL_NPM_REGISTRY}`,
968
1051
  `${name}@${version}`,
@@ -0,0 +1,85 @@
1
+ import { probeCodexBridgeArmed } from './codex-app-wake.js';
2
+ import { checkInboxMonitorHealthy } from './stream-status.js';
3
+ import {
4
+ getOpenCodeConnectionState,
5
+ type OpenCodeConnectionState,
6
+ } from './opencode-drone.js';
7
+ import type { AgentKind } from './agent-runtime.js';
8
+
9
+ export interface WakePathSnapshot {
10
+ agentKind: AgentKind;
11
+ healthy: boolean | null;
12
+ openCode: OpenCodeConnectionState | null;
13
+ }
14
+
15
+ interface InspectWakePathInputs {
16
+ agentKind: AgentKind;
17
+ active: { cubeId: string; droneId: string } | null;
18
+ inboxPath: string | null;
19
+ monitorStateRoot: string | null;
20
+ }
21
+
22
+ interface InspectWakePathDeps {
23
+ checkClaudeMonitor?: typeof checkInboxMonitorHealthy;
24
+ probeCodex?: typeof probeCodexBridgeArmed;
25
+ getOpenCodeState?: typeof getOpenCodeConnectionState;
26
+ }
27
+
28
+ export function openCodeWakePathHealthy(
29
+ state: OpenCodeConnectionState,
30
+ ): boolean | null {
31
+ if (!state.connected) return false;
32
+ if (
33
+ state.deliveryStates.failed > 0 ||
34
+ state.deliveryStates['delivered-unconfirmed'] > 0
35
+ ) {
36
+ return false;
37
+ }
38
+ if (
39
+ state.deliveryStates.queued > 0 ||
40
+ state.deliveryStates.retried > 0
41
+ ) {
42
+ return null;
43
+ }
44
+ if (state.sessionId === null) return null;
45
+ return true;
46
+ }
47
+
48
+ export async function inspectWakePath(
49
+ inputs: InspectWakePathInputs,
50
+ deps: InspectWakePathDeps = {},
51
+ ): Promise<WakePathSnapshot> {
52
+ if (!inputs.active) {
53
+ return {
54
+ agentKind: inputs.agentKind,
55
+ healthy: null,
56
+ openCode: null,
57
+ };
58
+ }
59
+
60
+ if (inputs.agentKind === 'claude') {
61
+ const check = deps.checkClaudeMonitor ?? checkInboxMonitorHealthy;
62
+ return {
63
+ agentKind: inputs.agentKind,
64
+ healthy: check(inputs.inboxPath, inputs.monitorStateRoot),
65
+ openCode: null,
66
+ };
67
+ }
68
+
69
+ if (inputs.agentKind === 'codex') {
70
+ const probe = deps.probeCodex ?? probeCodexBridgeArmed;
71
+ return {
72
+ agentKind: inputs.agentKind,
73
+ healthy: await probe(inputs.active),
74
+ openCode: null,
75
+ };
76
+ }
77
+
78
+ const getState = deps.getOpenCodeState ?? getOpenCodeConnectionState;
79
+ const openCode = getState();
80
+ return {
81
+ agentKind: inputs.agentKind,
82
+ healthy: openCodeWakePathHealthy(openCode),
83
+ openCode,
84
+ };
85
+ }