borgmcp 2.12.1 → 2.14.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 (57) hide show
  1. package/dist/assimilate-cmd.d.ts.map +1 -1
  2. package/dist/assimilate-cmd.js +8 -4
  3. package/dist/assimilate-cmd.js.map +1 -1
  4. package/dist/claude.d.ts +23 -0
  5. package/dist/claude.d.ts.map +1 -1
  6. package/dist/claude.js +46 -26
  7. package/dist/claude.js.map +1 -1
  8. package/dist/codex-app-wake.d.ts +3 -3
  9. package/dist/codex-app-wake.d.ts.map +1 -1
  10. package/dist/codex-app-wake.js +10 -8
  11. package/dist/codex-app-wake.js.map +1 -1
  12. package/dist/cube-activity-wake-copy.d.ts +3 -0
  13. package/dist/cube-activity-wake-copy.d.ts.map +1 -0
  14. package/dist/cube-activity-wake-copy.js +5 -0
  15. package/dist/cube-activity-wake-copy.js.map +1 -0
  16. package/dist/inbox-monitor.d.ts +1 -1
  17. package/dist/inbox-monitor.d.ts.map +1 -1
  18. package/dist/inbox-monitor.js +6 -3
  19. package/dist/inbox-monitor.js.map +1 -1
  20. package/dist/index.d.ts +25 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +49 -27
  23. package/dist/index.js.map +1 -1
  24. package/dist/log-stream.d.ts +2 -1
  25. package/dist/log-stream.d.ts.map +1 -1
  26. package/dist/log-stream.js +44 -12
  27. package/dist/log-stream.js.map +1 -1
  28. package/dist/opencode-drone.d.ts +12 -3
  29. package/dist/opencode-drone.d.ts.map +1 -1
  30. package/dist/opencode-drone.js +60 -5
  31. package/dist/opencode-drone.js.map +1 -1
  32. package/dist/opencode-wake-copy.d.ts.map +1 -1
  33. package/dist/opencode-wake-copy.js +3 -1
  34. package/dist/opencode-wake-copy.js.map +1 -1
  35. package/dist/remote-client.d.ts +5 -0
  36. package/dist/remote-client.d.ts.map +1 -1
  37. package/dist/remote-client.js +36 -11
  38. package/dist/remote-client.js.map +1 -1
  39. package/dist/roster-render.d.ts +2 -0
  40. package/dist/roster-render.d.ts.map +1 -1
  41. package/dist/roster-render.js +4 -4
  42. package/dist/roster-render.js.map +1 -1
  43. package/docs/EXTRACTION_PROVENANCE.md +8 -7
  44. package/docs/LOCAL_SERVER.md +3 -7
  45. package/docs/RELEASING.md +10 -6
  46. package/package.json +2 -2
  47. package/src/assimilate-cmd.ts +8 -4
  48. package/src/claude.ts +68 -27
  49. package/src/codex-app-wake.ts +15 -8
  50. package/src/cube-activity-wake-copy.ts +6 -0
  51. package/src/inbox-monitor.ts +7 -3
  52. package/src/index.ts +65 -26
  53. package/src/log-stream.ts +49 -16
  54. package/src/opencode-drone.ts +73 -5
  55. package/src/opencode-wake-copy.ts +4 -1
  56. package/src/remote-client.ts +41 -14
  57. package/src/roster-render.ts +6 -4
@@ -21,12 +21,16 @@ import {
21
21
  shouldFireHeartbeat,
22
22
  type CodexThreadInfo,
23
23
  } from './codex-wake-resolve.js';
24
+ import {
25
+ CUBE_ACTIVITY_RESUME_WAKE_MESSAGE,
26
+ formatCubeActivityWakeMessage,
27
+ } from './cube-activity-wake-copy.js';
24
28
 
25
29
  export const CODEX_WAKE_PROMPT =
26
- 'New Borg cube-log activity arrived.';
30
+ CUBE_ACTIVITY_RESUME_WAKE_MESSAGE;
27
31
 
28
32
  export function formatCodexWakePrompt(inboxLine: string): string {
29
- return `New Borg cube-log activity arrived:\n${inboxLine}`;
33
+ return formatCubeActivityWakeMessage(inboxLine);
30
34
  }
31
35
 
32
36
  // gh#708: STATIC catch-up/drain prompt (zero interpolation — no token/secret/PII/
@@ -36,7 +40,8 @@ export function formatCodexWakePrompt(inboxLine: string): string {
36
40
  // fire the already-shipped drain so no entry is skipped. gh#857 WI-2 reuses it as
37
41
  // the periodic heartbeat prompt.
38
42
  export const CODEX_CATCHUP_PROMPT =
39
- 'Borg cube activity arrived while you were busy. Wake triage: run `borg_read-log unread_only=true` and DRAIN — repeat until the returned page is under the limit and behind_by is 0 so no entries are skipped. Then handle actionable entries; if none, resume the prior interrupted work.';
43
+ `${formatCubeActivityWakeMessage('Wake triage: repeat the drain until the returned page is under the limit and behind_by is 0 so no entries are skipped.')} ` +
44
+ 'If there are no actionable entries, resume the prior interrupted work.';
40
45
 
41
46
  export function isCodexRemoteWakeEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
42
47
  return env[BORG_CODEX_REMOTE_WAKE_ENV] === '1';
@@ -92,6 +97,7 @@ export async function probeCodexBridgeArmed(
92
97
  let wakeInFlight = false;
93
98
  const pendingWakeRequests: Array<{
94
99
  reason: string;
100
+ deliveryIdentity?: string;
95
101
  deps: CodexWakeDeps;
96
102
  }> = [];
97
103
  const deliveredWakeKeys = new Set<string>();
@@ -272,11 +278,12 @@ async function maybePersistWakeTarget(
272
278
  export function wakeCodexViaAppServer(
273
279
  reason: string = CODEX_WAKE_PROMPT,
274
280
  env: NodeJS.ProcessEnv = process.env,
275
- deps: CodexWakeDeps = {}
281
+ deps: CodexWakeDeps = {},
282
+ deliveryIdentity?: string,
276
283
  ): void {
277
284
  const target = resolveCodexWakeTarget(env);
278
285
  if (!target.enabled) return;
279
- pendingWakeRequests.push({ reason, deps });
286
+ pendingWakeRequests.push({ reason, deliveryIdentity, deps });
280
287
  if (wakeInFlight) return;
281
288
 
282
289
  wakeInFlight = true;
@@ -288,11 +295,11 @@ export function wakeCodexViaAppServer(
288
295
  async function drainCodexWakeQueue(): Promise<void> {
289
296
  while (pendingWakeRequests.length > 0) {
290
297
  const request = pendingWakeRequests.shift()!;
291
- await wakeCodexTargeted(request.reason, request.deps);
298
+ await wakeCodexTargeted(request.reason, request.deliveryIdentity, request.deps);
292
299
  }
293
300
  }
294
301
 
295
- async function wakeCodexTargeted(reason: string, deps: CodexWakeDeps): Promise<void> {
302
+ async function wakeCodexTargeted(reason: string, deliveryIdentity: string | undefined, deps: CodexWakeDeps): Promise<void> {
296
303
  // gh#861 finding 1: another path (heartbeat/retry-drain) is mid-inject into the
297
304
  // same thread — defer to the retry-drain so this entry isn't double-injected nor
298
305
  // lost (the drain re-syncs the whole burst via the server read-cursor).
@@ -308,7 +315,7 @@ async function wakeCodexTargeted(reason: string, deps: CodexWakeDeps): Promise<v
308
315
  const resolved = await resolveFreshCodexWakeTarget(active, deps);
309
316
  if (!resolved) return;
310
317
  const { socketPath, threadId } = resolved;
311
- const wakeKey = `${threadId}\0${reason}`;
318
+ const wakeKey = `${threadId}\0${deliveryIdentity ?? reason}`;
312
319
  if (deliveredWakeKeys.has(wakeKey)) return; // dedup before opening the wake socket
313
320
  const client = makeCodexClient(socketPath, deps);
314
321
  await client.connect();
@@ -0,0 +1,6 @@
1
+ export const CUBE_ACTIVITY_RESUME_WAKE_MESSAGE =
2
+ 'Borg cube activity arrived while you were busy. Reading cube messages does not end your current task. Drain `borg_read-log unread_only=true` until caught up, handle actionable entries, then RESUME the interrupted work.';
3
+
4
+ export function formatCubeActivityWakeMessage(detail: string): string {
5
+ return `${CUBE_ACTIVITY_RESUME_WAKE_MESSAGE}\n${detail}`;
6
+ }
@@ -36,6 +36,7 @@ import { chmodSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync,
36
36
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
37
37
  import { createInterface } from 'node:readline';
38
38
  import { fileURLToPath } from 'node:url';
39
+ import { formatCubeActivityWakeMessage } from './cube-activity-wake-copy.js';
39
40
 
40
41
  const ENTRY_LINE_RE =
41
42
  /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+\(([^)]+)\):\s*(.*)$/;
@@ -87,11 +88,14 @@ export function formatEventLine(inboxLine: string): string | null {
87
88
 
88
89
  export function formatFreshEventLine(
89
90
  inboxLine: string,
90
- deduper: RecentLineDeduper
91
+ deduper: RecentLineDeduper,
92
+ includeWakeMessage = false,
91
93
  ): string | null {
92
94
  const pretty = formatEventLine(inboxLine);
93
95
  if (pretty === null) return null;
94
- return deduper.remember(inboxLine) ? pretty : null;
96
+ return deduper.remember(inboxLine)
97
+ ? includeWakeMessage ? formatCubeActivityWakeMessage(pretty) : pretty
98
+ : null;
95
99
  }
96
100
 
97
101
  export function seedDeduperFromInboxTail(
@@ -914,7 +918,7 @@ function main(): void {
914
918
 
915
919
  const rl = createInterface({ input: tail.stdout, crlfDelay: Infinity });
916
920
  rl.on('line', (line) => {
917
- const pretty = formatFreshEventLine(line, deduper);
921
+ const pretty = formatFreshEventLine(line, deduper, true);
918
922
  if (pretty !== null) {
919
923
  console.log(pretty);
920
924
  // Delivered → the tail is current; re-anchor the offset to the live
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  createRole,
42
42
  updateRole,
43
43
  patchRoleSection,
44
+ sanitizeServerAdvisory,
44
45
  patchTaxonomyClass,
45
46
  deleteRole,
46
47
  getCube,
@@ -132,7 +133,9 @@ import { resolveReportableSessionAgentKind } from './agent-runtime.js';
132
133
  import {
133
134
  connectOpenCodeDrone,
134
135
  injectOpenCodeEntry,
135
- computeOpenCodePort,
136
+ configuredOpenCodePort,
137
+ OPEN_CODE_PORT_MISSING_DIAGNOSTIC,
138
+ openCodeLaunchBinding,
136
139
  } from './opencode-drone.js';
137
140
  import { installBorgPlugin } from './opencode-plugin.js';
138
141
  import { setModuleInjectOpenCode } from './log-stream.js';
@@ -216,6 +219,57 @@ async function requireActiveCube() {
216
219
  return active;
217
220
  }
218
221
 
222
+ export function appendServerAdvisory(text: string, advisory: unknown): string {
223
+ const sanitized = sanitizeServerAdvisory(advisory);
224
+ return sanitized === undefined ? text : `${text}\n\nAdvisory: ${sanitized}`;
225
+ }
226
+
227
+ export function formatUpdatedCubeResult(cube: { name: string; id: string }, advisory?: unknown): string {
228
+ return appendServerAdvisory(`Updated cube **${cube.name}** (id: ${cube.id}).`, advisory);
229
+ }
230
+
231
+ export function formatUpdatedRoleResult(role: { name: string; id: string; role_class?: string; is_human_seat?: boolean; is_default?: boolean; is_mandatory?: boolean }, advisory?: unknown): string {
232
+ const tags = [
233
+ role.role_class === 'queen' ? 'Queen' : null,
234
+ role.is_human_seat ? 'human-seat' : null,
235
+ role.is_default ? 'default' : null,
236
+ role.is_mandatory ? 'mandatory' : null,
237
+ ].filter(Boolean).join(', ');
238
+ const tag = tags ? ` (${tags})` : '';
239
+ return appendServerAdvisory(`Updated role **${role.name}**${tag} (id: ${role.id}).`, advisory);
240
+ }
241
+
242
+ export function formatPatchedRoleSectionResult(action: 'replace' | 'insert' | 'delete', heading: string, role: { name: string; id: string }, advisory?: unknown): string {
243
+ const verb = action === 'replace' ? 'Replaced' : action === 'insert' ? 'Inserted' : 'Deleted';
244
+ return appendServerAdvisory(`${verb} section **${heading}** in role **${role.name}** (id: ${role.id}).`, advisory);
245
+ }
246
+
247
+ export async function connectOpenCodeRuntime(
248
+ active: {
249
+ worktree?: string;
250
+ droneLabel: string;
251
+ name: string;
252
+ },
253
+ env: NodeJS.ProcessEnv = process.env,
254
+ deps: {
255
+ connect?: typeof connectOpenCodeDrone;
256
+ } = {},
257
+ ): Promise<boolean> {
258
+ const configuredPort = configuredOpenCodePort(env);
259
+ if (configuredPort === null) {
260
+ console.error(OPEN_CODE_PORT_MISSING_DIAGNOSTIC);
261
+ return false;
262
+ }
263
+ const binding = openCodeLaunchBinding(configuredPort);
264
+ await (deps.connect ?? connectOpenCodeDrone)({
265
+ serverUrl: binding.serverUrl,
266
+ directory: active.worktree ?? findProjectRoot(),
267
+ droneLabel: active.droneLabel,
268
+ cubeName: active.name,
269
+ });
270
+ return true;
271
+ }
272
+
219
273
  /**
220
274
  * Main entry point - MCP stdio server
221
275
  */
@@ -266,15 +320,7 @@ export async function main() {
266
320
  installBorgPlugin();
267
321
  const active = await getActiveCube();
268
322
  if (active && openCodeRuntime) {
269
- const port = computeOpenCodePort(active.droneId);
270
- const serverUrl = `http://127.0.0.1:${port}`;
271
- await connectOpenCodeDrone({
272
- serverUrl,
273
- directory: active.worktree ?? findProjectRoot(),
274
- droneLabel: active.droneLabel,
275
- cubeName: active.name,
276
- });
277
- setModuleInjectOpenCode(injectOpenCodeEntry);
323
+ if (await connectOpenCodeRuntime(active)) setModuleInjectOpenCode(injectOpenCodeEntry);
278
324
  }
279
325
  },
280
326
  };
@@ -1018,8 +1064,8 @@ export async function main() {
1018
1064
  if (typeof args?.cube_directive === 'string') updates.cube_directive = args.cube_directive as string;
1019
1065
  if (Array.isArray(args?.message_taxonomy)) updates.message_taxonomy = args.message_taxonomy as MessageTaxonomy;
1020
1066
  if (Object.keys(updates).length === 0) throw new Error('Pass at least one of: cube_directive, message_taxonomy.');
1021
- const { cube } = await updateCube(cubeId, updates);
1022
- return { content: [{ type: 'text', text: `Updated cube **${cube.name}** (id: ${cube.id}).` }] };
1067
+ const { cube, advisory } = await updateCube(cubeId, updates);
1068
+ return { content: [{ type: 'text', text: formatUpdatedCubeResult(cube, advisory) }] };
1023
1069
  }
1024
1070
 
1025
1071
  case 'borg_patch-taxonomy-class': {
@@ -1108,15 +1154,8 @@ export async function main() {
1108
1154
  if (typeof args?.receives_all_direct === 'boolean') updates.receives_all_direct = args.receives_all_direct as boolean;
1109
1155
  if (typeof args?.default_model === 'string') updates.default_model = args.default_model as string;
1110
1156
  if (Object.keys(updates).length === 0) throw new Error('Pass at least one of: name, short_description, detailed_description, is_default, is_mandatory, is_human_seat, can_broadcast, receives_all_direct.');
1111
- const { role } = await updateRole(roleId, updates);
1112
- const tags = [
1113
- role.role_class === 'queen' ? 'Queen' : null,
1114
- role.is_human_seat ? 'human-seat' : null,
1115
- role.is_default ? 'default' : null,
1116
- role.is_mandatory ? 'mandatory' : null,
1117
- ].filter(Boolean).join(', ');
1118
- const tag = tags ? ` (${tags})` : '';
1119
- return { content: [{ type: 'text', text: `Updated role **${role.name}**${tag} (id: ${role.id}).` }] };
1157
+ const { role, advisory } = await updateRole(roleId, updates);
1158
+ return { content: [{ type: 'text', text: formatUpdatedRoleResult(role, advisory) }] };
1120
1159
  }
1121
1160
 
1122
1161
  case 'borg_patch-role-section': {
@@ -1129,8 +1168,9 @@ export async function main() {
1129
1168
  const heading = args?.heading as string;
1130
1169
  if (!heading) throw new Error('heading is required');
1131
1170
  let role: any;
1171
+ let advisory: unknown;
1132
1172
  if (action === 'delete') {
1133
- ({ role } = await patchRoleSection(roleId, { action, heading }));
1173
+ ({ role, advisory } = await patchRoleSection(roleId, { action, heading }));
1134
1174
  } else {
1135
1175
  const body = args?.body as string;
1136
1176
  if (typeof body !== 'string') {
@@ -1138,13 +1178,12 @@ export async function main() {
1138
1178
  }
1139
1179
  if (action === 'insert') {
1140
1180
  const after = (typeof args?.after === 'string' ? args.after : null) as string | null;
1141
- ({ role } = await patchRoleSection(roleId, { action, heading, body, after }));
1181
+ ({ role, advisory } = await patchRoleSection(roleId, { action, heading, body, after }));
1142
1182
  } else {
1143
- ({ role } = await patchRoleSection(roleId, { action, heading, body }));
1183
+ ({ role, advisory } = await patchRoleSection(roleId, { action, heading, body }));
1144
1184
  }
1145
1185
  }
1146
- const verb = action === 'replace' ? 'Replaced' : action === 'insert' ? 'Inserted' : 'Deleted';
1147
- return { content: [{ type: 'text', text: `${verb} section **${heading}** in role **${role.name}** (id: ${role.id}).` }] };
1186
+ return { content: [{ type: 'text', text: formatPatchedRoleSectionResult(action, heading, role, advisory) }] };
1148
1187
  }
1149
1188
 
1150
1189
  case 'borg_delete-role': {
package/src/log-stream.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  startCodexHeartbeat,
56
56
  wakeCodexViaAppServer,
57
57
  } from './codex-app-wake.js';
58
+ import { formatCubeActivityWakeMessage } from './cube-activity-wake-copy.js';
58
59
  import { readBoundedResponseBody } from './server-response.js';
59
60
  import { BorgServerError } from './server-errors.js';
60
61
  import { markSeatRejected } from './seats.js';
@@ -140,6 +141,14 @@ export function setModuleInjectOpenCode(
140
141
  * ring buffer.
141
142
  */
142
143
  const RECENT_IDS_CAP = 50;
144
+
145
+ function formatOpenCodeWakePrompt(line: string, wakeNonce?: string): string {
146
+ const prompt = formatCubeActivityWakeMessage(line);
147
+ return wakeNonce === undefined
148
+ ? prompt
149
+ : `${prompt}\n\n<!-- borg-wake-nonce:${wakeNonce} -->`;
150
+ }
151
+
143
152
  export const INBOX_TAIL_LINES_CAP = 512;
144
153
  export const INBOX_TAIL_TRIM_THRESHOLD_LINES = INBOX_TAIL_LINES_CAP * 2;
145
154
 
@@ -382,7 +391,7 @@ export interface StreamDeps {
382
391
  renderedLine: string
383
392
  ) => Promise<boolean>;
384
393
  /** Optional Codex app-server wake sink; tests inject a spy. */
385
- wakeCodex?: (reason: string) => void;
394
+ wakeCodex?: (reason: string, deliveryIdentity?: string) => void;
386
395
  /** Override the heartbeat watchdog timeout. */
387
396
  heartbeatTimeoutMs?: number;
388
397
  /** Override HWM divergence grace for focused tests. */
@@ -407,7 +416,8 @@ const defaultDeps: Required<StreamDeps> = {
407
416
  getCursor: getLocalServerCursor,
408
417
  appendLine: defaultAppendLine,
409
418
  hasInboxEntryId: defaultHasInboxEntryId,
410
- wakeCodex: wakeCodexViaAppServer,
419
+ wakeCodex: (reason, deliveryIdentity) =>
420
+ wakeCodexViaAppServer(reason, process.env, {}, deliveryIdentity),
411
421
  heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,
412
422
  hwmDivergenceGraceMs: HWM_DIVERGENCE_GRACE_MS,
413
423
  abortSignal: new AbortController().signal,
@@ -830,23 +840,27 @@ export async function streamOnce(
830
840
  ev: Extract<ParsedEvent, { type: 'log' }>
831
841
  ): Promise<'persisted-skip' | 'written'> => {
832
842
  const line = formatInboxLine(withSseEventId(ev.data, ev.id));
833
- const alreadyPersisted = isCatchingUp && (
843
+ const deliveryId = ev.wake_nonce ?? ev.id;
844
+ const isReping = ev.wake_nonce !== undefined;
845
+ const alreadyPersisted = (ev.wake_nonce !== undefined || isCatchingUp) && (
834
846
  // gh#441: pass the rendered line so the dedup can also recognize LEGACY
835
847
  // (no-entry_id-prefix) on-disk lines, not just the [entry_id:] marker.
836
848
  await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line)
837
849
  );
838
850
  if (alreadyPersisted) {
839
- // Replay still re-enters the OpenCode delivery queue with the same entry
840
- // ID. The queue confirms/deduplicates it by the unique persisted entry text.
841
- await injectOpenCode(line, ev.id, false);
851
+ // Replay still re-enters the OpenCode delivery queue. Ordinary entries
852
+ // correlate by canonical inbox text; re-pings add a stable nonce marker
853
+ // to the injected text so each distinct nonce submits exactly once.
854
+ await injectOpenCode(formatOpenCodeWakePrompt(line, ev.wake_nonce), deliveryId, isReping);
842
855
  markEventPersisted(ev.id, ev.data?.created_at ?? '');
843
856
  return 'persisted-skip';
844
857
  }
845
858
  // The inbox append is the durable record. OpenCode injection is only the
846
859
  // wake attempt and may return before the agent finishes processing.
847
860
  await appendLine(active.cubeId, active.droneId, line);
848
- if (!(await injectOpenCode(line, ev.id, true))) {
849
- wakeCodex(formatCodexWakePrompt(line));
861
+ if (!(await injectOpenCode(formatOpenCodeWakePrompt(line, ev.wake_nonce), deliveryId, true))) {
862
+ if (ev.wake_nonce === undefined) wakeCodex(formatCodexWakePrompt(line));
863
+ else wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce);
850
864
  }
851
865
  return 'written';
852
866
  };
@@ -1007,7 +1021,7 @@ export async function streamOnce(
1007
1021
  const entryId =
1008
1022
  event.id ??
1009
1023
  `control:eviction:${active.cubeId}:${active.droneId}:${event.reason ?? ''}`;
1010
- await injectOpenCode(line, entryId, true);
1024
+ await injectOpenCode(formatCubeActivityWakeMessage(line), entryId, true);
1011
1025
  } catch {
1012
1026
  // Inbox write failed — the Path-B 410 backstop still tears the drone
1013
1027
  // down on its next authed call. Best-effort wake only.
@@ -1064,6 +1078,10 @@ export async function streamOnce(
1064
1078
  continue;
1065
1079
  }
1066
1080
  if (event.type === 'log') {
1081
+ const isHeartbeatPing =
1082
+ typeof event.data?.message === 'string' &&
1083
+ event.data.message.startsWith('[HEARTBEAT-PING]');
1084
+
1067
1085
  // DEDUP per §(3) recent-ids contract: an out-of-order DO
1068
1086
  // broadcast followed by reconnect+catchup can replay an entry
1069
1087
  // we already persisted. The entry IS on disk from an earlier
@@ -1073,10 +1091,14 @@ export async function streamOnce(
1073
1091
  // Last-Event-ID on the next reconnect reflects the highest
1074
1092
  // id we've actually got persisted). UUIDs are not ordinally
1075
1093
  // comparable with created_at, so set membership is the check.
1094
+ const isRecentWakeReplay = recentIds.has(event.id) && event.wake_nonce !== undefined;
1076
1095
  if (recentIds.has(event.id)) {
1096
+ // Nonce replays continue through every ordinary routing filter below;
1097
+ // only their durable append is skipped. Other duplicates retain the
1098
+ // historical fast path.
1077
1099
  markEventPersisted(event.id, event.data?.created_at ?? '');
1078
1100
  markBroadcastPersisted(broadcastHwmFromLogEvent(event));
1079
- continue;
1101
+ if (!isRecentWakeReplay) continue;
1080
1102
  }
1081
1103
 
1082
1104
  // OWN-DRONE FILTER: restore the silent-self property — parity
@@ -1103,10 +1125,6 @@ export async function streamOnce(
1103
1125
  // drone through the disk-write path; the existing rate-limit
1104
1126
  // in the server heartbeat contract (max 1 ping per drone per ~1h)
1105
1127
  // bounds the silent-self property's relaxation.
1106
- const isHeartbeatPing =
1107
- typeof event.data?.message === 'string' &&
1108
- event.data.message.startsWith('[HEARTBEAT-PING]');
1109
-
1110
1128
  // Sprint 26 ack-fan-out: ack events have `kind: 'ack'` plus an
1111
1129
  // `author_drone_id` field naming the recipient (the author of
1112
1130
  // the entry that got acked). Only the author writes the ack
@@ -1117,6 +1135,10 @@ export async function streamOnce(
1117
1135
  // here BEFORE the legacy own-drone filter so the ack-specific
1118
1136
  // semantic takes precedence.
1119
1137
  if (event.data?.kind === 'ack') {
1138
+ if (isRecentWakeReplay) {
1139
+ await recordSeen(event);
1140
+ continue;
1141
+ }
1120
1142
  if (event.data?.author_drone_id === active.droneId) {
1121
1143
  if ((await writeInboxLine(event)) === 'persisted-skip') continue;
1122
1144
  }
@@ -1151,6 +1173,15 @@ export async function streamOnce(
1151
1173
  continue;
1152
1174
  }
1153
1175
 
1176
+ if (isRecentWakeReplay) {
1177
+ const line = formatInboxLine(withSseEventId(event.data, event.id));
1178
+ const wakeNonce = event.wake_nonce;
1179
+ if (wakeNonce !== undefined && !(await injectOpenCode(formatOpenCodeWakePrompt(line, wakeNonce), wakeNonce, true))) {
1180
+ wakeCodex(formatCodexWakePrompt(line), wakeNonce);
1181
+ }
1182
+ continue;
1183
+ }
1184
+
1154
1185
  // Regular inbound entry: write the inbox line (catchup-dedup aware),
1155
1186
  // then advance both cursors.
1156
1187
  if ((await writeInboxLine(event)) === 'persisted-skip') continue;
@@ -1206,7 +1237,7 @@ export async function streamOnceIfOwner(
1206
1237
  // ------------------------------------------------------------------
1207
1238
 
1208
1239
  export type ParsedEvent =
1209
- | { type: 'log'; id: string; data: any; cursor?: LocalServerCursor }
1240
+ | { type: 'log'; id: string; data: any; wake_nonce?: string; cursor?: LocalServerCursor }
1210
1241
  | { type: 'heartbeat'; ts: string | null; hwm: BroadcastHwm | null }
1211
1242
  | { type: 'bookmark'; as_of: string | null }
1212
1243
  // gh#877 Path-A: terminal eviction control frame (wake hint, zero authority).
@@ -1327,10 +1358,12 @@ function parseEventBlock(block: string): ParsedEvent | null {
1327
1358
  const validCursor = cursor &&
1328
1359
  typeof cursor.id === 'string' &&
1329
1360
  typeof cursor.created_at === 'string';
1361
+ const entry = parsed?.entry ?? parsed;
1330
1362
  return {
1331
1363
  type: 'log',
1332
1364
  id,
1333
- data: parsed?.entry ?? parsed,
1365
+ data: entry,
1366
+ ...(typeof entry?.wake_nonce === 'string' ? { wake_nonce: entry.wake_nonce } : {}),
1334
1367
  ...(validCursor ? { cursor } : {}),
1335
1368
  };
1336
1369
  }
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
2
2
  import { createHash, randomUUID } from 'crypto';
3
+ import { createServer } from 'node:net';
3
4
  import { join } from 'path';
4
5
  import { tmpdir } from 'os';
5
6
 
@@ -484,11 +485,15 @@ async function deliverOpenCodeEntry(
484
485
  log(`entry ${delivery.entryId} target unavailable: ${err}`);
485
486
  continue;
486
487
  }
487
- if (!target) return 'failed';
488
+ if (!target) {
489
+ log(`entry ${delivery.entryId} target unavailable: no bound session`);
490
+ return 'failed';
491
+ }
488
492
  }
489
493
 
490
494
  try {
491
495
  if (await findInjectedMessage(target.id, delivery.text)) {
496
+ log(`entry ${delivery.entryId} already present in session ${target.id}`);
492
497
  return 'delivered';
493
498
  }
494
499
  } catch (err) {
@@ -630,9 +635,10 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
630
635
 
631
636
  /**
632
637
  * 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.
638
+ * The injected text identifies the OpenCode-generated user message, so retries
639
+ * and replay can confirm an earlier ambiguous submission without supplying an
640
+ * ordering-breaking caller message ID or running it twice. Normal delivery uses
641
+ * canonical inbox text; wake re-pings include their stable nonce marker.
636
642
  */
637
643
  export function injectOpenCodeEntry(
638
644
  text: string,
@@ -640,7 +646,10 @@ export function injectOpenCodeEntry(
640
646
  allowSubmit: boolean = true,
641
647
  ): Promise<boolean> {
642
648
  const owner = state;
643
- if (!owner?.connected) return Promise.resolve(false);
649
+ if (!owner?.connected) {
650
+ log(`entry ${entryId} rejected: OpenCode is not connected`);
651
+ return Promise.resolve(false);
652
+ }
644
653
 
645
654
  const deliveredText = owner.deliveredEntries.get(entryId);
646
655
  if (deliveredText !== undefined) {
@@ -649,6 +658,7 @@ export function injectOpenCodeEntry(
649
658
  rememberBounded(owner.failedEntries, entryId, text);
650
659
  return Promise.resolve(false);
651
660
  }
661
+ log(`entry ${entryId} replay already delivered`);
652
662
  return Promise.resolve(true);
653
663
  }
654
664
 
@@ -659,6 +669,7 @@ export function injectOpenCodeEntry(
659
669
  rememberBounded(owner.failedEntries, entryId, text);
660
670
  return Promise.resolve(false);
661
671
  }
672
+ log(`entry ${entryId} replay remains unconfirmed`);
662
673
  return Promise.resolve(false);
663
674
  }
664
675
 
@@ -669,6 +680,7 @@ export function injectOpenCodeEntry(
669
680
  rememberBounded(owner.failedEntries, entryId, text);
670
681
  return Promise.resolve(false);
671
682
  }
683
+ log(`entry ${entryId} replay joined active delivery`);
672
684
  return active.promise;
673
685
  }
674
686
 
@@ -749,6 +761,62 @@ export function computeOpenCodePort(droneId: string, base: number = 14096): numb
749
761
  return base + (Math.abs(hash) % 1024);
750
762
  }
751
763
 
764
+ /**
765
+ * Ask the OS for an available loopback port. The old deterministic hash is
766
+ * retained above only for compatibility fixtures; launch paths must not use a
767
+ * bounded shared port space where two drones can collide.
768
+ */
769
+ async function canBindOpenCodePort(port: number): Promise<boolean> {
770
+ return new Promise((resolve) => {
771
+ const probe = createServer();
772
+ probe.once('error', () => resolve(false));
773
+ probe.listen(port, '127.0.0.1', () => {
774
+ probe.close(() => resolve(true));
775
+ });
776
+ });
777
+ }
778
+
779
+ export function configuredOpenCodePort(env: NodeJS.ProcessEnv = process.env): number | null {
780
+ const port = Number(env.BORG_OPENCODE_PORT);
781
+ return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null;
782
+ }
783
+
784
+ export const OPEN_CODE_PORT_MISSING_DIAGNOSTIC =
785
+ 'OpenCode launch port is missing; skipping OpenCode entry injection. Relaunch through borg.';
786
+
787
+ export function openCodeLaunchBinding(port: number): {
788
+ cliPort: string;
789
+ envPort: string;
790
+ serverUrl: string;
791
+ } {
792
+ const value = String(port);
793
+ return { cliPort: value, envPort: value, serverUrl: `http://127.0.0.1:${value}` };
794
+ }
795
+
796
+ export async function allocateOpenCodePort(
797
+ isPortAvailable: (port: number) => Promise<boolean> = canBindOpenCodePort,
798
+ ): Promise<number> {
799
+ for (let attempt = 0; attempt < 8; attempt++) {
800
+ const port = await new Promise<number>((resolve, reject) => {
801
+ const probe = createServer();
802
+ const fail = (error: Error) => {
803
+ probe.close(() => reject(error));
804
+ };
805
+ probe.once('error', fail);
806
+ probe.listen(0, '127.0.0.1', () => {
807
+ const address = probe.address();
808
+ if (address === null || typeof address === 'string') {
809
+ fail(new Error('OpenCode port allocation returned no TCP address'));
810
+ return;
811
+ }
812
+ probe.close((error) => error ? reject(error) : resolve(address.port));
813
+ });
814
+ });
815
+ if (await isPortAvailable(port)) return port;
816
+ }
817
+ throw new Error('OpenCode port allocation could not claim an available loopback port');
818
+ }
819
+
752
820
  /** Test-only cleanup for module state and the local cross-process binding. */
753
821
  export function __resetOpenCodeDroneForTests(): void {
754
822
  abandonOpenCodeDeliveries(state);
@@ -1,3 +1,5 @@
1
+ import { CUBE_ACTIVITY_RESUME_WAKE_MESSAGE } from './cube-activity-wake-copy.js';
2
+
1
3
  export const OPENCODE_WAKE_PATH_GUIDANCE =
2
4
  'OpenCode wakes through HTTP entry injection into its session through the local HTTP API. ' +
3
5
  'Borg writes each entry to the durable inbox before one injection attempt and ' +
@@ -5,4 +7,5 @@ export const OPENCODE_WAKE_PATH_GUIDANCE =
5
7
  'If injection is rejected or cannot be ' +
6
8
  'confirmed, the durable entry remains available and `borg_stream-status` reports ' +
7
9
  'it as failed or delivered-unconfirmed; run `borg_read-log unread_only=true` and ' +
8
- 'drain the unread log to recover.';
10
+ 'drain the unread log to recover. ' +
11
+ CUBE_ACTIVITY_RESUME_WAKE_MESSAGE;