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
package/src/index.ts CHANGED
@@ -90,10 +90,10 @@ import { filterToolsForRole } from './tool-scope.js';
90
90
  import { getPackageVersion, getOnDiskVersion, handleVersionFlag } from './version.js';
91
91
  import {
92
92
  renderStreamStatus,
93
- checkInboxMonitorHealthy,
94
93
  formatWakePathPrefix,
95
94
  shouldShowWakePathWarning,
96
95
  } from './stream-status.js';
96
+ import { inspectWakePath } from './wake-path-health.js';
97
97
  import {
98
98
  RUNTIME_METADATA_ADVISORY,
99
99
  renderRoster,
@@ -128,7 +128,6 @@ import {
128
128
  connectOpenCodeDrone,
129
129
  injectOpenCodeEntry,
130
130
  computeOpenCodePort,
131
- getOpenCodeConnectionState,
132
131
  } from './opencode-drone.js';
133
132
  import { installBorgPlugin } from './opencode-plugin.js';
134
133
  import { setModuleInjectOpenCode } from './log-stream.js';
@@ -401,31 +400,28 @@ export async function main() {
401
400
  await refreshActiveCubeMetadata(freshActive);
402
401
  }
403
402
 
404
- // Wake-path self-heal (gh#43): SSE delivery to the inbox file
405
- // is independent from Claude Code waking on file writes. The
406
- // latter requires a `tail -F` Monitor against the inbox path;
407
- // if that Monitor dies (or was never armed across a session
408
- // boundary), the drone misses every incoming entry until the
409
- // /loop fallback heartbeat. Because regen runs on every /loop
410
- // iteration, surfacing the breakage here gives self-healing at
411
- // worst-case latency = the heartbeat interval. Mirrors the
412
- // State-5 self-arm instruction in stream-status.ts.
403
+ // Wake-path self-heal (gh#43): SSE delivery to the durable inbox
404
+ // is distinct from the runtime-specific mechanism that wakes the
405
+ // agent. Inspect that real mechanism for Claude, Codex, or OpenCode
406
+ // and surface a runtime-specific recovery prefix when it is broken.
413
407
  const streamStatus = getStreamStatus();
414
408
  const inboxPath = inboxPathForDrone(freshActive.cubeId, freshActive.droneId);
415
409
  const monitorStateRoot = monitorStateRootForWorktree(findProjectRoot());
416
- // Non-Claude CLIs do not use the Claude inbox Monitor. Keep the
417
- // agent CLI distinction independent from whether Codex's optional
418
- // remote-wake transport is currently armed.
419
410
  const agentKind = resolveSessionAgentKind();
420
- const inboxMonitorHealthy = agentKind === 'claude'
421
- ? checkInboxMonitorHealthy(inboxPath, monitorStateRoot)
422
- : true;
411
+ const wakePath = await inspectWakePath({
412
+ agentKind,
413
+ active: freshActive,
414
+ inboxPath,
415
+ monitorStateRoot,
416
+ });
417
+ const inboxMonitorHealthy = wakePath.healthy;
423
418
  const prefix = shouldShowWakePathWarning(streamStatus, inboxMonitorHealthy)
424
419
  ? formatWakePathPrefix({
425
420
  inboxPath,
426
421
  monitorStateRoot,
427
422
  droneLabel: regenWakePathDroneLabel(displayedResult, displayIdentity.droneLabel),
428
423
  cubeName: displayIdentity.cubeName,
424
+ wakePath,
429
425
  })
430
426
  : '';
431
427
 
@@ -686,9 +682,9 @@ export async function main() {
686
682
  // Probe the in-process SSE consumer state. Does NOT require
687
683
  // an active cube — if the consumer hasn't started or is
688
684
  // between cubes, the snapshot still reports current values.
689
- // Also probes wake-path completeness (T1.2): is anyone tailing
690
- // the inbox file? Without that, SSE delivery still works but
691
- // the harness `/loop` never wakes on the file write.
685
+ // Also probes runtime-specific wake-path completeness: Claude's
686
+ // inbox Monitor, Codex's remote-control bridge, or OpenCode's
687
+ // durable injection delivery state.
692
688
  const status = getStreamStatus();
693
689
  const active = await getActiveCube();
694
690
  const inboxPath = active
@@ -697,14 +693,13 @@ export async function main() {
697
693
  const monitorStateRoot = active
698
694
  ? monitorStateRootForWorktree(findProjectRoot())
699
695
  : null;
700
- // Non-Claude CLIs have their own wake mechanism, so this Claude-only
701
- // Monitor diagnostic must not be inferred from Codex transport state.
702
- const nonClaudeSession = active && resolveSessionAgentKind() !== 'claude';
703
- const inboxMonitorHealthy = active
704
- ? nonClaudeSession
705
- ? true
706
- : checkInboxMonitorHealthy(inboxPath, monitorStateRoot)
707
- : null;
696
+ const wakePath = await inspectWakePath({
697
+ agentKind: resolveSessionAgentKind(),
698
+ active,
699
+ inboxPath,
700
+ monitorStateRoot,
701
+ });
702
+ const inboxMonitorHealthy = wakePath.healthy;
708
703
  let silentInertWarning = '';
709
704
  if (status.runLoopHealth === 'silent-inert') {
710
705
  silentInertWarning = '## ⚠ SSE stream loop silent-inert — run /mcp and reconnect to restart\n\n' +
@@ -713,6 +708,7 @@ export async function main() {
713
708
  const text = renderStreamStatus({
714
709
  status,
715
710
  inboxMonitorHealthy,
711
+ wakePath,
716
712
  inboxPath,
717
713
  monitorStateRoot,
718
714
  droneLabel: active?.droneLabel ?? null,
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 the unique persisted entry text.
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,26 @@ 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
+ allowSubmit: boolean;
71
+ state: Exclude<OpenCodeDeliveryState, 'failed'>;
72
+ resolve: (delivered: boolean) => void;
73
+ promise: Promise<boolean>;
74
+ }
75
+
76
+ type OpenCodeDeliveryOutcome = 'delivered' | 'delivered-unconfirmed' | 'failed';
77
+
78
+ const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const;
79
+ const OPEN_CODE_DELIVERY_HISTORY_LIMIT = 256;
80
+
53
81
  interface SessionBinding {
54
82
  version: 2;
55
83
  sessionId: string;
@@ -89,7 +117,18 @@ export function createOpenCodeLaunchKickoff(
89
117
 
90
118
  const bindingPathsForTests = new Set<string>();
91
119
 
120
+ function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
121
+ if (!current) return;
122
+ current.connected = false;
123
+ for (const delivery of current.activeDeliveries.values()) {
124
+ delivery.resolve(false);
125
+ }
126
+ current.activeDeliveries.clear();
127
+ current.deliveryQueue.length = 0;
128
+ }
129
+
92
130
  export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
131
+ abandonOpenCodeDeliveries(state);
93
132
  state = {
94
133
  serverUrl: deps.serverUrl,
95
134
  sessionId: null,
@@ -100,6 +139,13 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
100
139
  cubeName: deps.cubeName,
101
140
  connected: true,
102
141
  totalEntriesInjected: 0,
142
+ totalEntriesRetried: 0,
143
+ deliveryQueue: [],
144
+ activeDeliveries: new Map(),
145
+ deliveredEntries: new Map(),
146
+ unconfirmedEntries: new Map(),
147
+ failedEntries: new Map(),
148
+ processingDeliveries: false,
103
149
  };
104
150
  log(`connected url=${deps.serverUrl} dir=${deps.directory}`);
105
151
  }
@@ -153,13 +199,10 @@ async function listSessions(): Promise<OCSession[]> {
153
199
  }
154
200
 
155
201
  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
- }
202
+ const { status, body } = await rawGet(`/session/${id}`);
203
+ if (status === 404) return null;
204
+ if (status !== 200) throw new Error(`OpenCode session request failed (${status})`);
205
+ return JSON.parse(body);
163
206
  }
164
207
 
165
208
  async function listSessionMessages(id: string): Promise<OCMessage[]> {
@@ -168,9 +211,21 @@ async function listSessionMessages(id: string): Promise<OCMessage[]> {
168
211
  return JSON.parse(body);
169
212
  }
170
213
 
171
- async function promptSession(id: string, bodyObj: Record<string, unknown>): Promise<boolean> {
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
+ }
222
+ }
223
+ return null;
224
+ }
225
+
226
+ async function promptSession(id: string, bodyObj: Record<string, unknown>): Promise<number> {
172
227
  const { status } = await rawPost(`/session/${id}/prompt_async`, bodyObj);
173
- return status === 200 || status === 204;
228
+ return status;
174
229
  }
175
230
 
176
231
  // ---------------------------------------------------------------------------
@@ -381,11 +436,151 @@ async function resolveInjectionSession(): Promise<OCSession | null> {
381
436
  return bound;
382
437
  }
383
438
 
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;
439
+ function rememberBounded(
440
+ entries: Map<string, string>,
441
+ entryId: string,
442
+ text: string,
443
+ ): void {
444
+ entries.delete(entryId);
445
+ entries.set(entryId, text);
446
+ while (entries.size > OPEN_CODE_DELIVERY_HISTORY_LIMIT) {
447
+ const oldest = entries.keys().next().value;
448
+ if (typeof oldest !== 'string') break;
449
+ entries.delete(oldest);
450
+ }
451
+ }
452
+
453
+ function waitForDeliveryRetry(attempt: number): Promise<void> {
454
+ const delay = OPEN_CODE_DELIVERY_RETRY_DELAYS_MS[attempt] ?? 0;
455
+ return delay > 0
456
+ ? new Promise((resolve) => setTimeout(resolve, delay))
457
+ : Promise.resolve();
458
+ }
459
+
460
+ async function deliverOpenCodeEntry(
461
+ owner: OpenCodeDroneState,
462
+ delivery: OpenCodeDelivery,
463
+ ): Promise<OpenCodeDeliveryOutcome> {
464
+ let target: OCSession | null = null;
465
+
466
+ // Before the one allowed POST, retries are safe: no submission has happened.
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.
471
+ for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
472
+ if (state !== owner || !owner.connected) return 'failed';
473
+ if (attempt > 0) {
474
+ delivery.state = 'retried';
475
+ owner.totalEntriesRetried++;
476
+ await waitForDeliveryRetry(attempt);
477
+ if (state !== owner || !owner.connected) return 'failed';
478
+ }
479
+
480
+ if (!target) {
481
+ try {
482
+ target = await resolveInjectionSession();
483
+ } catch (err) {
484
+ log(`entry ${delivery.entryId} target unavailable: ${err}`);
485
+ continue;
486
+ }
487
+ if (!target) return 'failed';
488
+ }
489
+
490
+ try {
491
+ if (await findInjectedMessage(target.id, delivery.text)) {
492
+ return 'delivered';
493
+ }
494
+ } catch (err) {
495
+ log(`entry ${delivery.entryId} confirmation unavailable: ${err}`);
496
+ continue;
497
+ }
498
+
499
+ if (!delivery.allowSubmit) {
500
+ continue;
501
+ }
502
+
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
505
+ // confirmation-only path.
506
+ let status: number | null = null;
507
+ try {
508
+ status = await promptSession(target.id, {
509
+ parts: [{ type: 'text', text: delivery.text }],
510
+ });
511
+ } catch (err) {
512
+ log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
513
+ }
514
+
515
+ delivery.state = 'delivered-unconfirmed';
516
+ if (status !== null && status !== 200 && status !== 204) {
517
+ if (status === 404) clearBinding();
518
+ return 'failed';
519
+ }
520
+
521
+ for (
522
+ let confirmationAttempt = 0;
523
+ confirmationAttempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length;
524
+ confirmationAttempt++
525
+ ) {
526
+ if (confirmationAttempt > 0) {
527
+ delivery.state = 'retried';
528
+ owner.totalEntriesRetried++;
529
+ await waitForDeliveryRetry(confirmationAttempt);
530
+ if (state !== owner || !owner.connected) return 'delivered-unconfirmed';
531
+ delivery.state = 'delivered-unconfirmed';
532
+ }
533
+ try {
534
+ if (await findInjectedMessage(target.id, delivery.text)) {
535
+ return 'delivered';
536
+ }
537
+ } catch (err) {
538
+ log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`);
539
+ }
540
+ }
541
+
542
+ return 'delivered-unconfirmed';
543
+ }
544
+
545
+ // A replay begins from a durable inbox record but cannot know whether the
546
+ // prior process stopped before or after submission. Missing confirmation
547
+ // therefore remains unknown, not a definite delivery failure.
548
+ return delivery.allowSubmit ? 'failed' : 'delivered-unconfirmed';
549
+ }
550
+
551
+ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<void> {
552
+ if (owner.processingDeliveries) return;
553
+ owner.processingDeliveries = true;
554
+ try {
555
+ while (state === owner && owner.deliveryQueue.length > 0) {
556
+ const delivery = owner.deliveryQueue.shift()!;
557
+ let outcome: OpenCodeDeliveryOutcome = 'failed';
558
+ try {
559
+ outcome = await deliverOpenCodeEntry(owner, delivery);
560
+ } catch (err) {
561
+ log(`entry ${delivery.entryId} delivery error: ${err}`);
562
+ }
563
+
564
+ owner.activeDeliveries.delete(delivery.entryId);
565
+ if (outcome === 'delivered') {
566
+ owner.unconfirmedEntries.delete(delivery.entryId);
567
+ owner.failedEntries.delete(delivery.entryId);
568
+ rememberBounded(owner.deliveredEntries, delivery.entryId, delivery.text);
569
+ owner.totalEntriesInjected++;
570
+ delivery.resolve(true);
571
+ } else if (outcome === 'delivered-unconfirmed') {
572
+ owner.failedEntries.delete(delivery.entryId);
573
+ rememberBounded(owner.unconfirmedEntries, delivery.entryId, delivery.text);
574
+ delivery.resolve(false);
575
+ } else {
576
+ owner.unconfirmedEntries.delete(delivery.entryId);
577
+ rememberBounded(owner.failedEntries, delivery.entryId, delivery.text);
578
+ delivery.resolve(false);
579
+ }
580
+ }
581
+ } finally {
582
+ owner.processingDeliveries = false;
583
+ }
389
584
  }
390
585
 
391
586
  // ---------------------------------------------------------------------------
@@ -434,42 +629,67 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
434
629
  }
435
630
 
436
631
  /**
437
- * Inject a silent context entry (noReply) into our session.
438
- * Falls through silently caller falls back to inbox write.
632
+ * Queue one durable inbox entry for delivery into the bound OpenCode session.
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.
439
636
  */
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;
637
+ export function injectOpenCodeEntry(
638
+ text: string,
639
+ entryId: string = createHash('sha256').update(text).digest('hex'),
640
+ allowSubmit: boolean = true,
641
+ ): Promise<boolean> {
642
+ const owner = state;
643
+ if (!owner?.connected) return Promise.resolve(false);
644
+
645
+ const deliveredText = owner.deliveredEntries.get(entryId);
646
+ if (deliveredText !== undefined) {
647
+ if (deliveredText !== text) {
648
+ log(`entry ${entryId} replay text mismatch`);
649
+ rememberBounded(owner.failedEntries, entryId, text);
650
+ return Promise.resolve(false);
651
+ }
652
+ return Promise.resolve(true);
653
+ }
446
654
 
447
- const body = {
448
- parts: [{ type: 'text', text }],
449
- };
450
- if (await promptSession(target.id, body)) {
451
- state.totalEntriesInjected++;
452
- return true;
655
+ const unconfirmedText = owner.unconfirmedEntries.get(entryId);
656
+ if (unconfirmedText !== undefined) {
657
+ if (unconfirmedText !== text) {
658
+ log(`entry ${entryId} unconfirmed replay text mismatch`);
659
+ rememberBounded(owner.failedEntries, entryId, text);
660
+ return Promise.resolve(false);
453
661
  }
662
+ return Promise.resolve(false);
663
+ }
454
664
 
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;
665
+ const active = owner.activeDeliveries.get(entryId);
666
+ if (active) {
667
+ if (active.text !== text) {
668
+ log(`entry ${entryId} active text mismatch`);
669
+ rememberBounded(owner.failedEntries, entryId, text);
670
+ return Promise.resolve(false);
465
671
  }
466
- clearBinding();
467
- return false;
468
- } catch (err) {
469
- log(`entry error: ${err}`);
470
- clearBinding();
471
- return false;
672
+ return active.promise;
472
673
  }
674
+
675
+ let resolveDelivery!: (delivered: boolean) => void;
676
+ const promise = new Promise<boolean>((resolve) => {
677
+ resolveDelivery = resolve;
678
+ });
679
+ const delivery: OpenCodeDelivery = {
680
+ entryId,
681
+ text,
682
+ allowSubmit,
683
+ state: 'queued',
684
+ resolve: resolveDelivery,
685
+ promise,
686
+ };
687
+ owner.unconfirmedEntries.delete(entryId);
688
+ owner.failedEntries.delete(entryId);
689
+ owner.activeDeliveries.set(entryId, delivery);
690
+ owner.deliveryQueue.push(delivery);
691
+ void processOpenCodeDeliveries(owner);
692
+ return promise;
473
693
  }
474
694
 
475
695
  export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
@@ -483,24 +703,39 @@ export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
483
703
  clearBinding();
484
704
  return false;
485
705
  } catch {
486
- clearBinding();
487
706
  return false;
488
707
  }
489
708
  }
490
709
 
491
710
  export function disconnectOpenCodeDrone(): void {
711
+ abandonOpenCodeDeliveries(state);
492
712
  state = null;
493
713
  }
494
714
 
495
- export function getOpenCodeConnectionState(): {
715
+ export interface OpenCodeConnectionState {
496
716
  connected: boolean;
497
717
  sessionId: string | null;
498
718
  totalEntriesInjected: number;
499
- } {
719
+ totalEntriesRetried: number;
720
+ deliveryStates: Record<OpenCodeDeliveryState, number>;
721
+ }
722
+
723
+ export function getOpenCodeConnectionState(): OpenCodeConnectionState {
724
+ const deliveryStates: Record<OpenCodeDeliveryState, number> = {
725
+ queued: 0,
726
+ 'delivered-unconfirmed': state?.unconfirmedEntries.size ?? 0,
727
+ retried: 0,
728
+ failed: state?.failedEntries.size ?? 0,
729
+ };
730
+ for (const delivery of state?.activeDeliveries.values() ?? []) {
731
+ deliveryStates[delivery.state]++;
732
+ }
500
733
  return {
501
734
  connected: state?.connected ?? false,
502
735
  sessionId: state?.sessionId ?? null,
503
736
  totalEntriesInjected: state?.totalEntriesInjected ?? 0,
737
+ totalEntriesRetried: state?.totalEntriesRetried ?? 0,
738
+ deliveryStates,
504
739
  };
505
740
  }
506
741
 
@@ -516,6 +751,7 @@ export function computeOpenCodePort(droneId: string, base: number = 14096): numb
516
751
 
517
752
  /** Test-only cleanup for module state and the local cross-process binding. */
518
753
  export function __resetOpenCodeDroneForTests(): void {
754
+ abandonOpenCodeDeliveries(state);
519
755
  state = null;
520
756
  for (const path of bindingPathsForTests) {
521
757
  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 the unique persisted entry text; 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.';