borgmcp 2.4.1 → 2.6.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.
Files changed (84) 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.map +1 -1
  16. package/dist/codex-launch.js +2 -1
  17. package/dist/codex-launch.js.map +1 -1
  18. package/dist/first-run-server.d.ts +31 -0
  19. package/dist/first-run-server.d.ts.map +1 -0
  20. package/dist/first-run-server.js +117 -0
  21. package/dist/first-run-server.js.map +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +25 -27
  24. package/dist/index.js.map +1 -1
  25. package/dist/log-stream.d.ts +4 -8
  26. package/dist/log-stream.d.ts.map +1 -1
  27. package/dist/log-stream.js +22 -15
  28. package/dist/log-stream.js.map +1 -1
  29. package/dist/opencode-drone.d.ts +10 -5
  30. package/dist/opencode-drone.d.ts.map +1 -1
  31. package/dist/opencode-drone.js +241 -48
  32. package/dist/opencode-drone.js.map +1 -1
  33. package/dist/opencode-wake-copy.d.ts +2 -0
  34. package/dist/opencode-wake-copy.d.ts.map +1 -0
  35. package/dist/opencode-wake-copy.js +8 -0
  36. package/dist/opencode-wake-copy.js.map +1 -0
  37. package/dist/regen-format.d.ts.map +1 -1
  38. package/dist/regen-format.js +6 -4
  39. package/dist/regen-format.js.map +1 -1
  40. package/dist/repository-cube-init.d.ts +2 -0
  41. package/dist/repository-cube-init.d.ts.map +1 -1
  42. package/dist/repository-cube-init.js +14 -3
  43. package/dist/repository-cube-init.js.map +1 -1
  44. package/dist/server-facade.d.ts +2 -0
  45. package/dist/server-facade.d.ts.map +1 -1
  46. package/dist/server-facade.js +25 -3
  47. package/dist/server-facade.js.map +1 -1
  48. package/dist/server-handshake.d.ts +1 -1
  49. package/dist/setup.js +10 -0
  50. package/dist/setup.js.map +1 -1
  51. package/dist/stream-status.d.ts +9 -8
  52. package/dist/stream-status.d.ts.map +1 -1
  53. package/dist/stream-status.js +67 -17
  54. package/dist/stream-status.js.map +1 -1
  55. package/dist/update-cmd.d.ts +11 -1
  56. package/dist/update-cmd.d.ts.map +1 -1
  57. package/dist/update-cmd.js +69 -5
  58. package/dist/update-cmd.js.map +1 -1
  59. package/dist/wake-path-health.d.ts +27 -0
  60. package/dist/wake-path-health.d.ts.map +1 -0
  61. package/dist/wake-path-health.js +51 -0
  62. package/dist/wake-path-health.js.map +1 -0
  63. package/docs/EXTRACTION_PROVENANCE.md +8 -8
  64. package/docs/LOCAL_SERVER.md +14 -5
  65. package/docs/RELEASING.md +16 -6
  66. package/package.json +2 -2
  67. package/src/assimilate-cmd.ts +66 -27
  68. package/src/assimilate-deps.ts +9 -0
  69. package/src/claude.ts +10 -3
  70. package/src/cli-help.ts +7 -4
  71. package/src/codex-launch.ts +2 -1
  72. package/src/first-run-server.ts +178 -0
  73. package/src/index.ts +24 -28
  74. package/src/log-stream.ts +36 -25
  75. package/src/opencode-drone.ts +295 -47
  76. package/src/opencode-wake-copy.ts +8 -0
  77. package/src/regen-format.ts +6 -6
  78. package/src/repository-cube-init.ts +16 -3
  79. package/src/server-facade.ts +34 -3
  80. package/src/server-handshake.ts +1 -1
  81. package/src/setup.ts +11 -0
  82. package/src/stream-status.ts +85 -21
  83. package/src/update-cmd.ts +89 -6
  84. package/src/wake-path-health.ts +85 -0
package/src/log-stream.ts CHANGED
@@ -120,9 +120,13 @@ function isProcessAlive(pid: number): boolean {
120
120
  * startup (from index.ts) once the opencode drone module is initialized.
121
121
  * Used by defaultDeps when no explicit injectOpenCode is supplied.
122
122
  */
123
- let _moduleInjectOpenCode: ((text: string) => Promise<boolean>) | undefined;
123
+ let _moduleInjectOpenCode:
124
+ | ((text: string, entryId: string, allowSubmit: boolean) => Promise<boolean>)
125
+ | undefined;
124
126
 
125
- export function setModuleInjectOpenCode(fn: (text: string) => Promise<boolean>): void {
127
+ export function setModuleInjectOpenCode(
128
+ fn: (text: string, entryId: string, allowSubmit: boolean) => Promise<boolean>
129
+ ): void {
126
130
  _moduleInjectOpenCode = fn;
127
131
  }
128
132
 
@@ -387,13 +391,12 @@ export interface StreamDeps {
387
391
  ownerDeps?: import('./stream-owner.js').StreamOwnerDeps;
388
392
  /** Override owner stale threshold in focused duplicate-process tests. */
389
393
  ownerStaleMs?: number;
390
- /**
391
- * Optional opencode entry injector for autonomous drone processing.
392
- * When provided AND the injection succeeds, the inbox file write is skipped
393
- * (the drone processes the entry autonomously). On failure, falls through
394
- * to the inbox write for backup.
395
- */
396
- injectOpenCode?: (text: string) => Promise<boolean>;
394
+ /** Optional OpenCode wake delivery after the durable inbox append. */
395
+ injectOpenCode?: (
396
+ text: string,
397
+ entryId: string,
398
+ allowSubmit: boolean
399
+ ) => Promise<boolean>;
397
400
  }
398
401
 
399
402
  const defaultDeps: Required<StreamDeps> = {
@@ -408,7 +411,10 @@ const defaultDeps: Required<StreamDeps> = {
408
411
  abortSignal: new AbortController().signal,
409
412
  ownerDeps: {},
410
413
  ownerStaleMs: 70_000,
411
- injectOpenCode: (text) => _moduleInjectOpenCode ? _moduleInjectOpenCode(text) : Promise.resolve(false),
414
+ injectOpenCode: (text, entryId, allowSubmit) =>
415
+ _moduleInjectOpenCode
416
+ ? _moduleInjectOpenCode(text, entryId, allowSubmit)
417
+ : Promise.resolve(false),
412
418
  };
413
419
 
414
420
  // ------------------------------------------------------------------
@@ -814,24 +820,24 @@ export async function streamOnce(
814
820
  ev: Extract<ParsedEvent, { type: 'log' }>
815
821
  ): Promise<'persisted-skip' | 'written'> => {
816
822
  const line = formatInboxLine(withSseEventId(ev.data, ev.id));
817
- if (
818
- isCatchingUp &&
823
+ const alreadyPersisted = isCatchingUp && (
819
824
  // gh#441: pass the rendered line so the dedup can also recognize LEGACY
820
825
  // (no-entry_id-prefix) on-disk lines, not just the [entry_id:] marker.
821
- (await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line))
822
- ) {
826
+ await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line)
827
+ );
828
+ if (alreadyPersisted) {
829
+ // Replay still re-enters the OpenCode delivery queue with the same entry
830
+ // ID. The queue confirms/deduplicates it by stable OpenCode message ID.
831
+ await injectOpenCode(line, ev.id, false);
823
832
  markEventPersisted(ev.id, ev.data?.created_at ?? '');
824
833
  return 'persisted-skip';
825
834
  }
826
- // gh#opencode: try autonomous opencode injection first. When the drone's
827
- // child session processes entries directly, skip the inbox write (the
828
- // Monitor/tail-F path is unused for opencode). Falls through to inbox on
829
- // failure so the entry is never lost.
830
- if (await injectOpenCode(line)) {
831
- return 'written';
832
- }
835
+ // The inbox append is the durable record. OpenCode injection is only the
836
+ // wake attempt and may return before the agent finishes processing.
833
837
  await appendLine(active.cubeId, active.droneId, line);
834
- wakeCodex(formatCodexWakePrompt(line));
838
+ if (!(await injectOpenCode(line, ev.id, true))) {
839
+ wakeCodex(formatCodexWakePrompt(line));
840
+ }
835
841
  return 'written';
836
842
  };
837
843
 
@@ -978,11 +984,16 @@ export async function streamOnce(
978
984
  if (event.type === 'eviction') {
979
985
  streamState.lastContentEventAt = nowIso;
980
986
  try {
987
+ const line = formatEvictionSentinelLine(event.reason);
981
988
  await appendLine(
982
989
  active.cubeId,
983
990
  active.droneId,
984
- formatEvictionSentinelLine(event.reason)
991
+ line
985
992
  );
993
+ const entryId =
994
+ event.id ??
995
+ `control:eviction:${active.cubeId}:${active.droneId}:${event.reason ?? ''}`;
996
+ await injectOpenCode(line, entryId, true);
986
997
  } catch {
987
998
  // Inbox write failed — the Path-B 410 backstop still tears the drone
988
999
  // down on its next authed call. Best-effort wake only.
@@ -1178,7 +1189,7 @@ export type ParsedEvent =
1178
1189
  | { type: 'heartbeat'; ts: string | null; hwm: BroadcastHwm | null }
1179
1190
  | { type: 'bookmark'; as_of: string | null }
1180
1191
  // gh#877 Path-A: terminal eviction control frame (wake hint, zero authority).
1181
- | { type: 'eviction'; cube_id: string | null; reason: string | null }
1192
+ | { type: 'eviction'; id: string | null; cube_id: string | null; reason: string | null }
1182
1193
  | { type: 'unknown'; raw: string };
1183
1194
 
1184
1195
  /**
@@ -1336,7 +1347,7 @@ function parseEventBlock(block: string): ParsedEvent | null {
1336
1347
  } catch {
1337
1348
  // fall through with nulls
1338
1349
  }
1339
- return { type: 'eviction', cube_id, reason };
1350
+ return { type: 'eviction', id: id || null, cube_id, reason };
1340
1351
  }
1341
1352
  return { type: 'unknown', raw: block };
1342
1353
  }
@@ -19,6 +19,13 @@ interface OpenCodeDroneState {
19
19
  cubeName: string;
20
20
  connected: boolean;
21
21
  totalEntriesInjected: number;
22
+ totalEntriesRetried: number;
23
+ deliveryQueue: OpenCodeDelivery[];
24
+ activeDeliveries: Map<string, OpenCodeDelivery>;
25
+ deliveredEntries: Map<string, string>;
26
+ unconfirmedEntries: Map<string, string>;
27
+ failedEntries: Map<string, string>;
28
+ processingDeliveries: boolean;
22
29
  }
23
30
 
24
31
  let state: OpenCodeDroneState | null = null;
@@ -41,6 +48,7 @@ interface OCSession {
41
48
 
42
49
  interface OCMessage {
43
50
  info?: {
51
+ id?: string;
44
52
  role?: string;
45
53
  time?: { created?: number };
46
54
  };
@@ -50,6 +58,27 @@ interface OCMessage {
50
58
  }>;
51
59
  }
52
60
 
61
+ export type OpenCodeDeliveryState =
62
+ | 'queued'
63
+ | 'delivered-unconfirmed'
64
+ | 'retried'
65
+ | 'failed';
66
+
67
+ interface OpenCodeDelivery {
68
+ entryId: string;
69
+ text: string;
70
+ messageId: string;
71
+ allowSubmit: boolean;
72
+ state: Exclude<OpenCodeDeliveryState, 'failed'>;
73
+ resolve: (delivered: boolean) => void;
74
+ promise: Promise<boolean>;
75
+ }
76
+
77
+ type OpenCodeDeliveryOutcome = 'delivered' | 'delivered-unconfirmed' | 'failed';
78
+
79
+ const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const;
80
+ const OPEN_CODE_DELIVERY_HISTORY_LIMIT = 256;
81
+
53
82
  interface SessionBinding {
54
83
  version: 2;
55
84
  sessionId: string;
@@ -89,7 +118,18 @@ export function createOpenCodeLaunchKickoff(
89
118
 
90
119
  const bindingPathsForTests = new Set<string>();
91
120
 
121
+ function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
122
+ if (!current) return;
123
+ current.connected = false;
124
+ for (const delivery of current.activeDeliveries.values()) {
125
+ delivery.resolve(false);
126
+ }
127
+ current.activeDeliveries.clear();
128
+ current.deliveryQueue.length = 0;
129
+ }
130
+
92
131
  export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
132
+ abandonOpenCodeDeliveries(state);
93
133
  state = {
94
134
  serverUrl: deps.serverUrl,
95
135
  sessionId: null,
@@ -100,6 +140,13 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
100
140
  cubeName: deps.cubeName,
101
141
  connected: true,
102
142
  totalEntriesInjected: 0,
143
+ totalEntriesRetried: 0,
144
+ deliveryQueue: [],
145
+ activeDeliveries: new Map(),
146
+ deliveredEntries: new Map(),
147
+ unconfirmedEntries: new Map(),
148
+ failedEntries: new Map(),
149
+ processingDeliveries: false,
103
150
  };
104
151
  log(`connected url=${deps.serverUrl} dir=${deps.directory}`);
105
152
  }
@@ -153,13 +200,10 @@ async function listSessions(): Promise<OCSession[]> {
153
200
  }
154
201
 
155
202
  async function getSession(id: string): Promise<OCSession | null> {
156
- try {
157
- const { status, body } = await rawGet(`/session/${id}`);
158
- if (status !== 200) return null;
159
- return JSON.parse(body);
160
- } catch {
161
- return null;
162
- }
203
+ const { status, body } = await rawGet(`/session/${id}`);
204
+ if (status === 404) return null;
205
+ if (status !== 200) throw new Error(`OpenCode session request failed (${status})`);
206
+ return JSON.parse(body);
163
207
  }
164
208
 
165
209
  async function listSessionMessages(id: string): Promise<OCMessage[]> {
@@ -168,9 +212,27 @@ async function listSessionMessages(id: string): Promise<OCMessage[]> {
168
212
  return JSON.parse(body);
169
213
  }
170
214
 
171
- async function promptSession(id: string, bodyObj: Record<string, unknown>): Promise<boolean> {
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');
229
+ }
230
+ return 'found';
231
+ }
232
+
233
+ async function promptSession(id: string, bodyObj: Record<string, unknown>): Promise<number> {
172
234
  const { status } = await rawPost(`/session/${id}/prompt_async`, bodyObj);
173
- return status === 200 || status === 204;
235
+ return status;
174
236
  }
175
237
 
176
238
  // ---------------------------------------------------------------------------
@@ -381,11 +443,156 @@ async function resolveInjectionSession(): Promise<OCSession | null> {
381
443
  return bound;
382
444
  }
383
445
 
384
- async function rebindAfterFailure(knownRootSessionIds: string[]): Promise<OCSession | null> {
385
- const replacement = await findUnseenTopLevelSession(knownRootSessionIds);
386
- if (!replacement) return null;
387
- saveBinding(replacement.session, replacement.knownRootSessionIds);
388
- return replacement.session;
446
+ function openCodeMessageId(entryId: string): string {
447
+ const digest = createHash('sha256').update(entryId).digest('hex');
448
+ return `msg_borg_${digest}`;
449
+ }
450
+
451
+ function rememberBounded(
452
+ entries: Map<string, string>,
453
+ entryId: string,
454
+ text: string,
455
+ ): void {
456
+ entries.delete(entryId);
457
+ entries.set(entryId, text);
458
+ while (entries.size > OPEN_CODE_DELIVERY_HISTORY_LIMIT) {
459
+ const oldest = entries.keys().next().value;
460
+ if (typeof oldest !== 'string') break;
461
+ entries.delete(oldest);
462
+ }
463
+ }
464
+
465
+ function waitForDeliveryRetry(attempt: number): Promise<void> {
466
+ const delay = OPEN_CODE_DELIVERY_RETRY_DELAYS_MS[attempt] ?? 0;
467
+ return delay > 0
468
+ ? new Promise((resolve) => setTimeout(resolve, delay))
469
+ : Promise.resolve();
470
+ }
471
+
472
+ async function deliverOpenCodeEntry(
473
+ owner: OpenCodeDroneState,
474
+ delivery: OpenCodeDelivery,
475
+ ): Promise<OpenCodeDeliveryOutcome> {
476
+ let target: OCSession | null = null;
477
+
478
+ // 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.
481
+ for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
482
+ if (state !== owner || !owner.connected) return 'failed';
483
+ if (attempt > 0) {
484
+ delivery.state = 'retried';
485
+ owner.totalEntriesRetried++;
486
+ await waitForDeliveryRetry(attempt);
487
+ if (state !== owner || !owner.connected) return 'failed';
488
+ }
489
+
490
+ if (!target) {
491
+ try {
492
+ target = await resolveInjectionSession();
493
+ } catch (err) {
494
+ log(`entry ${delivery.entryId} target unavailable: ${err}`);
495
+ continue;
496
+ }
497
+ if (!target) return 'failed';
498
+ }
499
+
500
+ try {
501
+ if (await getSessionMessage(target.id, delivery.messageId) === 'found') {
502
+ return 'delivered';
503
+ }
504
+ } catch (err) {
505
+ log(`entry ${delivery.entryId} confirmation unavailable: ${err}`);
506
+ continue;
507
+ }
508
+
509
+ if (!delivery.allowSubmit) {
510
+ continue;
511
+ }
512
+
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
516
+ // confirmation-only path.
517
+ let status: number | null = null;
518
+ try {
519
+ status = await promptSession(target.id, {
520
+ messageID: delivery.messageId,
521
+ parts: [{ type: 'text', text: delivery.text }],
522
+ });
523
+ } catch (err) {
524
+ log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
525
+ }
526
+
527
+ delivery.state = 'delivered-unconfirmed';
528
+ if (status !== null && status !== 200 && status !== 204) {
529
+ if (status === 404) clearBinding();
530
+ return 'failed';
531
+ }
532
+
533
+ for (
534
+ let confirmationAttempt = 0;
535
+ confirmationAttempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length;
536
+ confirmationAttempt++
537
+ ) {
538
+ if (confirmationAttempt > 0) {
539
+ delivery.state = 'retried';
540
+ owner.totalEntriesRetried++;
541
+ await waitForDeliveryRetry(confirmationAttempt);
542
+ if (state !== owner || !owner.connected) return 'delivered-unconfirmed';
543
+ delivery.state = 'delivered-unconfirmed';
544
+ }
545
+ try {
546
+ if (await getSessionMessage(target.id, delivery.messageId) === 'found') {
547
+ return 'delivered';
548
+ }
549
+ } catch (err) {
550
+ log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`);
551
+ }
552
+ }
553
+
554
+ return 'delivered-unconfirmed';
555
+ }
556
+
557
+ // A replay begins from a durable inbox record but cannot know whether the
558
+ // prior process stopped before or after submission. Missing confirmation
559
+ // therefore remains unknown, not a definite delivery failure.
560
+ return delivery.allowSubmit ? 'failed' : 'delivered-unconfirmed';
561
+ }
562
+
563
+ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<void> {
564
+ if (owner.processingDeliveries) return;
565
+ owner.processingDeliveries = true;
566
+ try {
567
+ while (state === owner && owner.deliveryQueue.length > 0) {
568
+ const delivery = owner.deliveryQueue.shift()!;
569
+ let outcome: OpenCodeDeliveryOutcome = 'failed';
570
+ try {
571
+ outcome = await deliverOpenCodeEntry(owner, delivery);
572
+ } catch (err) {
573
+ log(`entry ${delivery.entryId} delivery error: ${err}`);
574
+ }
575
+
576
+ owner.activeDeliveries.delete(delivery.entryId);
577
+ if (outcome === 'delivered') {
578
+ owner.unconfirmedEntries.delete(delivery.entryId);
579
+ owner.failedEntries.delete(delivery.entryId);
580
+ rememberBounded(owner.deliveredEntries, delivery.entryId, delivery.text);
581
+ owner.totalEntriesInjected++;
582
+ delivery.resolve(true);
583
+ } else if (outcome === 'delivered-unconfirmed') {
584
+ owner.failedEntries.delete(delivery.entryId);
585
+ rememberBounded(owner.unconfirmedEntries, delivery.entryId, delivery.text);
586
+ delivery.resolve(true);
587
+ } else {
588
+ owner.unconfirmedEntries.delete(delivery.entryId);
589
+ rememberBounded(owner.failedEntries, delivery.entryId, delivery.text);
590
+ delivery.resolve(false);
591
+ }
592
+ }
593
+ } finally {
594
+ owner.processingDeliveries = false;
595
+ }
389
596
  }
390
597
 
391
598
  // ---------------------------------------------------------------------------
@@ -434,42 +641,67 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
434
641
  }
435
642
 
436
643
  /**
437
- * Inject a silent context entry (noReply) into our session.
438
- * Falls through silently caller falls back to inbox write.
644
+ * 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.
439
647
  */
440
- export async function injectOpenCodeEntry(text: string): Promise<boolean> {
441
- if (!state?.connected) return false;
442
-
443
- try {
444
- const target = await resolveInjectionSession();
445
- if (!target) return false;
648
+ export function injectOpenCodeEntry(
649
+ text: string,
650
+ entryId: string = createHash('sha256').update(text).digest('hex'),
651
+ allowSubmit: boolean = true,
652
+ ): Promise<boolean> {
653
+ const owner = state;
654
+ if (!owner?.connected) return Promise.resolve(false);
655
+
656
+ const deliveredText = owner.deliveredEntries.get(entryId);
657
+ if (deliveredText !== undefined) {
658
+ if (deliveredText !== text) {
659
+ log(`entry ${entryId} replay text mismatch`);
660
+ rememberBounded(owner.failedEntries, entryId, text);
661
+ return Promise.resolve(false);
662
+ }
663
+ return Promise.resolve(true);
664
+ }
446
665
 
447
- const body = {
448
- parts: [{ type: 'text', text }],
449
- };
450
- if (await promptSession(target.id, body)) {
451
- state.totalEntriesInjected++;
452
- return true;
666
+ const unconfirmedText = owner.unconfirmedEntries.get(entryId);
667
+ if (unconfirmedText !== undefined) {
668
+ if (unconfirmedText !== text) {
669
+ log(`entry ${entryId} unconfirmed replay text mismatch`);
670
+ rememberBounded(owner.failedEntries, entryId, text);
671
+ return Promise.resolve(false);
453
672
  }
673
+ return Promise.resolve(true);
674
+ }
454
675
 
455
- // A failed prompt means the cached target is no longer trustworthy. Clear
456
- // it before considering only a newer root created by `/new`; never fall
457
- // through to a child or arbitrary newest session.
458
- const knownRootSessionIds = [...state.knownRootSessionIds];
459
- clearBinding();
460
- const replacement = await rebindAfterFailure(knownRootSessionIds);
461
- if (!replacement) return false;
462
- if (await promptSession(replacement.id, body)) {
463
- state.totalEntriesInjected++;
464
- return true;
676
+ const active = owner.activeDeliveries.get(entryId);
677
+ if (active) {
678
+ if (active.text !== text) {
679
+ log(`entry ${entryId} active text mismatch`);
680
+ rememberBounded(owner.failedEntries, entryId, text);
681
+ return Promise.resolve(false);
465
682
  }
466
- clearBinding();
467
- return false;
468
- } catch (err) {
469
- log(`entry error: ${err}`);
470
- clearBinding();
471
- return false;
683
+ return active.promise;
472
684
  }
685
+
686
+ let resolveDelivery!: (delivered: boolean) => void;
687
+ const promise = new Promise<boolean>((resolve) => {
688
+ resolveDelivery = resolve;
689
+ });
690
+ const delivery: OpenCodeDelivery = {
691
+ entryId,
692
+ text,
693
+ messageId: openCodeMessageId(entryId),
694
+ allowSubmit,
695
+ state: 'queued',
696
+ resolve: resolveDelivery,
697
+ promise,
698
+ };
699
+ owner.unconfirmedEntries.delete(entryId);
700
+ owner.failedEntries.delete(entryId);
701
+ owner.activeDeliveries.set(entryId, delivery);
702
+ owner.deliveryQueue.push(delivery);
703
+ void processOpenCodeDeliveries(owner);
704
+ return promise;
473
705
  }
474
706
 
475
707
  export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
@@ -483,24 +715,39 @@ export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
483
715
  clearBinding();
484
716
  return false;
485
717
  } catch {
486
- clearBinding();
487
718
  return false;
488
719
  }
489
720
  }
490
721
 
491
722
  export function disconnectOpenCodeDrone(): void {
723
+ abandonOpenCodeDeliveries(state);
492
724
  state = null;
493
725
  }
494
726
 
495
- export function getOpenCodeConnectionState(): {
727
+ export interface OpenCodeConnectionState {
496
728
  connected: boolean;
497
729
  sessionId: string | null;
498
730
  totalEntriesInjected: number;
499
- } {
731
+ totalEntriesRetried: number;
732
+ deliveryStates: Record<OpenCodeDeliveryState, number>;
733
+ }
734
+
735
+ export function getOpenCodeConnectionState(): OpenCodeConnectionState {
736
+ const deliveryStates: Record<OpenCodeDeliveryState, number> = {
737
+ queued: 0,
738
+ 'delivered-unconfirmed': state?.unconfirmedEntries.size ?? 0,
739
+ retried: 0,
740
+ failed: state?.failedEntries.size ?? 0,
741
+ };
742
+ for (const delivery of state?.activeDeliveries.values() ?? []) {
743
+ deliveryStates[delivery.state]++;
744
+ }
500
745
  return {
501
746
  connected: state?.connected ?? false,
502
747
  sessionId: state?.sessionId ?? null,
503
748
  totalEntriesInjected: state?.totalEntriesInjected ?? 0,
749
+ totalEntriesRetried: state?.totalEntriesRetried ?? 0,
750
+ deliveryStates,
504
751
  };
505
752
  }
506
753
 
@@ -516,6 +763,7 @@ export function computeOpenCodePort(droneId: string, base: number = 14096): numb
516
763
 
517
764
  /** Test-only cleanup for module state and the local cross-process binding. */
518
765
  export function __resetOpenCodeDroneForTests(): void {
766
+ abandonOpenCodeDeliveries(state);
519
767
  state = null;
520
768
  for (const path of bindingPathsForTests) {
521
769
  try {
@@ -0,0 +1,8 @@
1
+ export const OPENCODE_WAKE_PATH_GUIDANCE =
2
+ 'OpenCode wakes through HTTP entry injection into its session through the local HTTP API. ' +
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. ' +
5
+ 'If injection is rejected or cannot be ' +
6
+ 'confirmed, the durable entry remains available and `borg_stream-status` reports ' +
7
+ 'it as failed or delivered-unconfirmed; run `borg_read-log unread_only=true` and ' +
8
+ 'drain the unread log to recover.';
@@ -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).
@@ -85,11 +86,7 @@ export function wakePathArming(
85
86
  ].join(' ');
86
87
  }
87
88
  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
- );
89
+ return OPENCODE_WAKE_PATH_GUIDANCE;
93
90
  }
94
91
  // gh#client#18: use absolute path to THIS installation's borg-inbox-monitor
95
92
  // so the orientation command always resolves to the same version as the
@@ -249,7 +246,9 @@ You're a Drone in a Cube. Coordinate with other drones through the activity log.
249
246
  2. Apply your role's conventions to each entry. Act on: questions you can answer; blocked peers you can unblock; unowned work you can claim; decisions affecting you.
250
247
  3. Actionable signal → act + post the convention. Don't wait to be asked.
251
248
  4. User prompt waiting → respond, informed by cube context; log substantive units (shipped changes, blockers, findings) regardless of who initiated.
252
- 5. Nothing actionable + no prompt done; wait for next wake.
249
+ 5. Holding unfinished assigned work? Resume it now, in this turn. An acknowledgement
250
+ or a status reply must never be the last action of a turn while work is outstanding.
251
+ 6. Nothing actionable, no prompt, and no work of your own outstanding → done; wait for next wake.
253
252
 
254
253
  **On a \`<task-notification>\` wake:** the payload is a truncatable preview; the full entry is in the DB. Drain: \`borg_read-log unread_only=true limit=20\`, repeat until \`behind_by=0\`. Do NOT triage with \`since=<notification timestamp>\` (strict-after — skips the boundary entry) or a bare window (skips older-unread during bursts).
255
254
  ${arrivalInstruction}
@@ -261,6 +260,7 @@ ${arrivalInstruction}
261
260
  **When stuck:** post your blocker per your role's conventions, continue other work. Escalation is per your role detail, not by stalling.
262
261
 
263
262
  **Anti-passive (lane idle = no work routed to you, no actionable signal in the log):**
263
+ **Completion contract:** A work item is finished when you post its completion or blocked signal, not when you answer a question. If you hold unfinished assigned work, your lane is not idle; resume it in this turn.
264
264
  - If your work arrives via dispatch / a work queue: when your lane goes idle, post your role's availability signal (capacity clean, awaiting next assignment from your coordinating role) — once per idle period, don't spam. No assignment in ~15 min → ping your coordinating role (capacity available since <time>; any queue item to pick up?).
265
265
  - If your work is SELF-DIRECTED (not dispatch-driven): do NOT post an availability signal — proactively surface lane-substantive work per your role (reviews, audits, proposals, coherence / quality sweeps on relevant in-flight work).
266
266
  - Route work-asks through your cube's coordinating role, never directly to the human Queen.
@@ -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
  }