borgmcp 2.6.0 → 2.7.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.
@@ -67,7 +67,6 @@ export type OpenCodeDeliveryState =
67
67
  interface OpenCodeDelivery {
68
68
  entryId: string;
69
69
  text: string;
70
- messageId: string;
71
70
  allowSubmit: boolean;
72
71
  state: Exclude<OpenCodeDeliveryState, 'failed'>;
73
72
  resolve: (delivered: boolean) => void;
@@ -212,22 +211,16 @@ async function listSessionMessages(id: string): Promise<OCMessage[]> {
212
211
  return JSON.parse(body);
213
212
  }
214
213
 
215
- async function getSessionMessage(
216
- sessionId: string,
217
- messageId: string,
218
- ): Promise<'found' | 'missing'> {
219
- const { status, body } = await rawGet(
220
- `/session/${sessionId}/message/${encodeURIComponent(messageId)}`,
221
- );
222
- if (status === 404) return 'missing';
223
- if (status !== 200) {
224
- throw new Error(`OpenCode message request failed (${status})`);
225
- }
226
- const message = JSON.parse(body) as OCMessage;
227
- if (message.info?.id !== messageId || message.info.role !== 'user') {
228
- throw new Error('OpenCode returned the wrong injected message');
214
+ async function findInjectedMessage(sessionId: string, text: string): Promise<string | null> {
215
+ const messages = await listSessionMessages(sessionId);
216
+ for (let index = messages.length - 1; index >= 0; index--) {
217
+ const message = messages[index];
218
+ if (message?.info?.role !== 'user' || typeof message.info.id !== 'string') continue;
219
+ if (message.parts?.some((part) => part.type === 'text' && part.text === text)) {
220
+ return message.info.id;
221
+ }
229
222
  }
230
- return 'found';
223
+ return null;
231
224
  }
232
225
 
233
226
  async function promptSession(id: string, bodyObj: Record<string, unknown>): Promise<number> {
@@ -443,11 +436,6 @@ async function resolveInjectionSession(): Promise<OCSession | null> {
443
436
  return bound;
444
437
  }
445
438
 
446
- function openCodeMessageId(entryId: string): string {
447
- const digest = createHash('sha256').update(entryId).digest('hex');
448
- return `msg_borg_${digest}`;
449
- }
450
-
451
439
  function rememberBounded(
452
440
  entries: Map<string, string>,
453
441
  entryId: string,
@@ -476,8 +464,10 @@ async function deliverOpenCodeEntry(
476
464
  let target: OCSession | null = null;
477
465
 
478
466
  // Before the one allowed POST, retries are safe: no submission has happened.
479
- // A replayed inbox entry sets allowSubmit=false, so it can only confirm an
480
- // earlier submission and can never manufacture a second prompt.
467
+ // OpenCode must generate the message ID: its run loop treats IDs as
468
+ // lexicographically ordered, so arbitrary caller IDs can persist without
469
+ // ever becoming the active user turn. The unique inbox text correlates the
470
+ // generated message across confirmation and process-replay instead.
481
471
  for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
482
472
  if (state !== owner || !owner.connected) return 'failed';
483
473
  if (attempt > 0) {
@@ -498,7 +488,7 @@ async function deliverOpenCodeEntry(
498
488
  }
499
489
 
500
490
  try {
501
- if (await getSessionMessage(target.id, delivery.messageId) === 'found') {
491
+ if (await findInjectedMessage(target.id, delivery.text)) {
502
492
  return 'delivered';
503
493
  }
504
494
  } catch (err) {
@@ -510,14 +500,12 @@ async function deliverOpenCodeEntry(
510
500
  continue;
511
501
  }
512
502
 
513
- // OpenCode does not deduplicate repeated prompt_async calls by messageID:
514
- // they append duplicate parts. Submit at most once, then only poll the
515
- // exact message. A transport failure is ambiguous and follows the same
503
+ // prompt_async is not idempotent. Submit at most once, then only poll for
504
+ // the exact text. A transport failure is ambiguous and follows the same
516
505
  // confirmation-only path.
517
506
  let status: number | null = null;
518
507
  try {
519
508
  status = await promptSession(target.id, {
520
- messageID: delivery.messageId,
521
509
  parts: [{ type: 'text', text: delivery.text }],
522
510
  });
523
511
  } catch (err) {
@@ -543,7 +531,7 @@ async function deliverOpenCodeEntry(
543
531
  delivery.state = 'delivered-unconfirmed';
544
532
  }
545
533
  try {
546
- if (await getSessionMessage(target.id, delivery.messageId) === 'found') {
534
+ if (await findInjectedMessage(target.id, delivery.text)) {
547
535
  return 'delivered';
548
536
  }
549
537
  } catch (err) {
@@ -583,7 +571,7 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
583
571
  } else if (outcome === 'delivered-unconfirmed') {
584
572
  owner.failedEntries.delete(delivery.entryId);
585
573
  rememberBounded(owner.unconfirmedEntries, delivery.entryId, delivery.text);
586
- delivery.resolve(true);
574
+ delivery.resolve(false);
587
575
  } else {
588
576
  owner.unconfirmedEntries.delete(delivery.entryId);
589
577
  rememberBounded(owner.failedEntries, delivery.entryId, delivery.text);
@@ -642,8 +630,9 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
642
630
 
643
631
  /**
644
632
  * Queue one durable inbox entry for delivery into the bound OpenCode session.
645
- * The SSE entry ID becomes a stable OpenCode message ID, so retries and replay
646
- * can confirm an earlier ambiguous submission without running it twice.
633
+ * The durable inbox text identifies the OpenCode-generated user message, so
634
+ * retries and replay can confirm an earlier ambiguous submission without
635
+ * supplying an ordering-breaking caller message ID or running it twice.
647
636
  */
648
637
  export function injectOpenCodeEntry(
649
638
  text: string,
@@ -670,7 +659,7 @@ export function injectOpenCodeEntry(
670
659
  rememberBounded(owner.failedEntries, entryId, text);
671
660
  return Promise.resolve(false);
672
661
  }
673
- return Promise.resolve(true);
662
+ return Promise.resolve(false);
674
663
  }
675
664
 
676
665
  const active = owner.activeDeliveries.get(entryId);
@@ -690,7 +679,6 @@ export function injectOpenCodeEntry(
690
679
  const delivery: OpenCodeDelivery = {
691
680
  entryId,
692
681
  text,
693
- messageId: openCodeMessageId(entryId),
694
682
  allowSubmit,
695
683
  state: 'queued',
696
684
  resolve: resolveDelivery,
@@ -1,7 +1,7 @@
1
1
  export const OPENCODE_WAKE_PATH_GUIDANCE =
2
2
  'OpenCode wakes through HTTP entry injection into its session through the local HTTP API. ' +
3
3
  'Borg writes each entry to the durable inbox before one injection attempt and ' +
4
- 'confirms delivery by exact message ID; no inbox Monitor or secondary wake loop is used. ' +
4
+ 'confirms delivery by the unique persisted entry text; no inbox Monitor or secondary wake loop is used. ' +
5
5
  'If injection is rejected or cannot be ' +
6
6
  'confirmed, the durable entry remains available and `borg_stream-status` reports ' +
7
7
  'it as failed or delivered-unconfirmed; run `borg_read-log unread_only=true` and ' +
@@ -27,9 +27,8 @@ import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
27
27
  * one of `startup` / `resume` / `clear` / `compact`. The `borg-regen`
28
28
  * SessionStart hook uses this to detect a `/clear` re-orientation, which is
29
29
  * the FIRST time the hook is the SOLE orientation path (the launch kickoff
30
- * prompt is gone) AND the moment Claude Code clears the session-scoped
31
- * `/loop` + `ScheduleWakeup` — so the re-injected orientation must instruct
32
- * an operational re-arm.
30
+ * prompt is gone), so the re-injected orientation must instruct an operational
31
+ * Monitor re-arm.
33
32
  *
34
33
  * Best-effort + total: empty input (manual / TTY run with no stdin),
35
34
  * malformed JSON, a missing `source`, or a non-string `source` all return
@@ -60,9 +59,8 @@ export type AgentKind = 'claude' | 'codex' | 'opencode';
60
59
  *
61
60
  * Agent-branched on the existing env-agnostic signal (BORG_SESSION-style
62
61
  * `isCodexRemoteWakeEnabled`), NOT on a mutable server-recorded field:
63
- * - claude: arm the inbox-file tail Monitor + engage `/loop` + maintain one
64
- * adaptive `ScheduleWakeup` recovery deadline (long while the Monitor is
65
- * 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 after an exit or whenever none is armed.
66
64
  * - codex: Borg's activity stream reaches the app-server remote-control inbox
67
65
  * channel; each wake is followed by an unread-log drain. Manual full regen
68
66
  * + drain is a degraded fallback when remote control is unavailable.
@@ -98,9 +96,8 @@ export function wakePathArming(
98
96
  return [
99
97
  'Arm your wake path before working:',
100
98
  `1. **Inbox Monitor** (wake path) — run a persistent Monitor on \`${monitorCommand}\` so cube posts wake you in real time.`,
101
- '2. **Engage `/loop`** (self-paced) so you keep waking to triage the cube.',
102
- '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.',
103
- '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** — re-arm the Monitor when its exit notification wakes you, and whenever you notice no Monitor is armed.',
104
101
  ].join('\n');
105
102
  }
106
103
 
@@ -165,8 +162,8 @@ export function formatLeanOrientation(args: {
165
162
  ? '\n_(`/clear` cleared your conversation; Codex remote-control wake remains active. Follow the required Codex wake path below.)_\n'
166
163
  : agentKind === 'claude'
167
164
  ? [
168
- '\n_(`/clear` cleared Claude\'s `/loop` and recovery deadline — re-arm them now.)_',
169
- '_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',
170
167
  ].join('\n')
171
168
  : ''
172
169
  : '';
@@ -158,7 +158,7 @@ function renderResult(
158
158
  `Next: borg assimilate --host ${shellEscape(input.serverOrigin)}`,
159
159
  );
160
160
  } else {
161
- lines.push('Continuing with role and seat setup...');
161
+ lines.push('Continuing with role and seat setup');
162
162
  }
163
163
  resultWriter(`${lines.join('\n')}\n`);
164
164
  }
@@ -176,6 +176,7 @@ export async function initializeRepositoryCube(input: {
176
176
  input.flags.template !== undefined || input.flags.noTemplate === true || input.flags.yes === true;
177
177
  const creationOptionsUnused = association !== null && creationOptionsRequested;
178
178
  if (association) {
179
+ deps.write(`Checking for this repository's cube on ${input.serverOrigin}…\n`);
179
180
  const cube = await deps.getCube(association.cubeId);
180
181
  const response: CreateCubeResponse = {
181
182
  result: 'resolved',
@@ -241,6 +242,7 @@ export async function initializeRepositoryCube(input: {
241
242
  return { kind: 'success', creation: { response, cube }, existing: true };
242
243
  };
243
244
 
245
+ deps.write(`Checking for this repository's cube on ${input.serverOrigin}…\n`);
244
246
  const serverAssociation = await deps.resolveAssociation(repository, input.context.derivedName);
245
247
  if (serverAssociation.result === 'resolved') {
246
248
  return saveResolvedAssociation(serverAssociation, {
@@ -263,9 +265,14 @@ export async function initializeRepositoryCube(input: {
263
265
  return { kind: 'stop', code: 1 };
264
266
  }
265
267
  if (!deps.isTTY() || input.flags.yes) {
268
+ const retryCommand = input.mode === 'cube-init'
269
+ ? `borg server cube init --host ${shellEscape(input.serverOrigin)}`
270
+ : `borg assimilate --host ${shellEscape(input.serverOrigin)}`;
266
271
  deps.write(
267
- 'Adopting an existing cube requires interactive confirmation; --yes is not accepted here.\n' +
268
- 'Rerun without --yes in an interactive terminal. No cube, repository binding, or drone was created.\n',
272
+ `Found existing cube '${matches[0].name}' on ${input.serverOrigin}.\n` +
273
+ 'Linking a repository to an existing cube requires one interactive confirmation.\n' +
274
+ `Run ${retryCommand} --cube-name ${shellEscape(matches[0].name)} once in an interactive terminal to link it; scripted runs work from then on.\n` +
275
+ 'No cube, repository binding, or drone was created.\n',
269
276
  );
270
277
  return { kind: 'stop', code: 1 };
271
278
  }
@@ -276,16 +283,17 @@ export async function initializeRepositoryCube(input: {
276
283
  ` server: ${input.serverOrigin}\n`,
277
284
  );
278
285
  while (true) {
279
- const answer = await ask(deps, 'Link this repository to that cube? [y/N]: ', 'adoption');
286
+ const answer = await ask(deps, 'Link this repository to that cube? [Y/n]: ', 'adoption');
280
287
  if ('stop' in answer) return { kind: 'stop', code: answer.stop };
281
288
  const confirmation = answer.value.trim().toLowerCase();
282
- if (confirmation === 'y' || confirmation === 'yes') break;
283
- if (confirmation === '' || confirmation === 'n' || confirmation === 'no') {
289
+ if (confirmation === '' || confirmation === 'y' || confirmation === 'yes') break;
290
+ if (confirmation === 'n' || confirmation === 'no') {
284
291
  deps.write('No cube, repository binding, or drone was created.\n');
285
292
  return { kind: 'stop', code: 0 };
286
293
  }
287
294
  deps.write('Enter y or n.\n');
288
295
  }
296
+ deps.write(`Linking this repository to cube '${matches[0].name}'…\n`);
289
297
  const associated = await deps.associateCube({
290
298
  cubeId: matches[0].id,
291
299
  workingRepoName: input.context.derivedName,
@@ -299,7 +307,11 @@ export async function initializeRepositoryCube(input: {
299
307
  if (confirmed.result !== 'resolved' || confirmed.cube_id !== associated.cube_id) {
300
308
  throw new RepositoryAssociationConfirmationError();
301
309
  }
302
- return await saveResolvedAssociation(confirmed, { adopted: true, creationOptionsUnused: creationOptionsRequested });
310
+ return await saveResolvedAssociation(confirmed, {
311
+ adopted: true,
312
+ creationOptionsUnused: input.flags.template !== undefined ||
313
+ input.flags.noTemplate === true,
314
+ });
303
315
  } catch (error) {
304
316
  if (error instanceof RepositoryAssociationSaveError) throw error;
305
317
  throw new RepositoryAssociationConfirmationError();
@@ -380,16 +392,12 @@ export async function initializeRepositoryCube(input: {
380
392
  template ??= 'software-dev';
381
393
 
382
394
  if (deps.isTTY() && !input.flags.yes) {
383
- deps.write(
384
- `Create this cube?\n` +
385
- ` Name: ${name}\n` +
386
- ` Template: ${presentation(template).label}\n` +
387
- ` Repository: ${input.context.root}\n` +
388
- ` Server: ${input.serverOrigin}\n`,
389
- );
390
395
  let confirmed = false;
391
396
  while (!confirmed) {
392
- const answer = await ask(deps, 'Create cube? [Y/n]: ');
397
+ const answer = await ask(
398
+ deps,
399
+ `Create cube '${name}' (${presentation(template).label}) on ${input.serverOrigin}? [Y/n]: `,
400
+ );
393
401
  if ('stop' in answer) return { kind: 'stop', code: answer.stop };
394
402
  const confirmation = answer.value.trim().toLowerCase();
395
403
  if (confirmation === '' || confirmation === 'y' || confirmation === 'yes') {
@@ -404,7 +412,7 @@ export async function initializeRepositoryCube(input: {
404
412
  }
405
413
  }
406
414
 
407
- deps.write('Creating cube...\n');
415
+ deps.write(`Creating cube '${name}'…\n`);
408
416
  const workingRepoName = input.context.derivedName;
409
417
  let creation: RepositoryCubeCreation;
410
418
  try {
@@ -385,7 +385,7 @@ export function formatWakePathPrefix(inputs: {
385
385
  return [
386
386
  `## ⚠ Wake path broken — arm Monitor NOW`,
387
387
  ``,
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 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:`,
389
389
  ``,
390
390
  `> Monitor command: \`${monitorCommand(inboxPath, monitorStateRoot)}\` — persistent, 1h timeout, description "borg inbox for ${droneLabel} on cube ${cubeName}".`,
391
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: {},