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.
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +8 -4
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/claude.d.ts +23 -0
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +46 -26
- package/dist/claude.js.map +1 -1
- package/dist/codex-app-wake.d.ts +3 -3
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +10 -8
- package/dist/codex-app-wake.js.map +1 -1
- package/dist/cube-activity-wake-copy.d.ts +3 -0
- package/dist/cube-activity-wake-copy.d.ts.map +1 -0
- package/dist/cube-activity-wake-copy.js +5 -0
- package/dist/cube-activity-wake-copy.js.map +1 -0
- package/dist/inbox-monitor.d.ts +1 -1
- package/dist/inbox-monitor.d.ts.map +1 -1
- package/dist/inbox-monitor.js +6 -3
- package/dist/inbox-monitor.js.map +1 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -27
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +2 -1
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +44 -12
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-drone.d.ts +12 -3
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +60 -5
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-wake-copy.d.ts.map +1 -1
- package/dist/opencode-wake-copy.js +3 -1
- package/dist/opencode-wake-copy.js.map +1 -1
- package/dist/remote-client.d.ts +5 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +36 -11
- package/dist/remote-client.js.map +1 -1
- package/dist/roster-render.d.ts +2 -0
- package/dist/roster-render.d.ts.map +1 -1
- package/dist/roster-render.js +4 -4
- package/dist/roster-render.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +8 -7
- package/docs/LOCAL_SERVER.md +3 -7
- package/docs/RELEASING.md +10 -6
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +8 -4
- package/src/claude.ts +68 -27
- package/src/codex-app-wake.ts +15 -8
- package/src/cube-activity-wake-copy.ts +6 -0
- package/src/inbox-monitor.ts +7 -3
- package/src/index.ts +65 -26
- package/src/log-stream.ts +49 -16
- package/src/opencode-drone.ts +73 -5
- package/src/opencode-wake-copy.ts +4 -1
- package/src/remote-client.ts +41 -14
- package/src/roster-render.ts +6 -4
package/src/codex-app-wake.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
+
CUBE_ACTIVITY_RESUME_WAKE_MESSAGE;
|
|
27
31
|
|
|
28
32
|
export function formatCodexWakePrompt(inboxLine: string): string {
|
|
29
|
-
return
|
|
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
|
-
'
|
|
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
|
+
}
|
package/src/inbox-monitor.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
840
|
-
//
|
|
841
|
-
|
|
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.
|
|
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:
|
|
1365
|
+
data: entry,
|
|
1366
|
+
...(typeof entry?.wake_nonce === 'string' ? { wake_nonce: entry.wake_nonce } : {}),
|
|
1334
1367
|
...(validCursor ? { cursor } : {}),
|
|
1335
1368
|
};
|
|
1336
1369
|
}
|
package/src/opencode-drone.ts
CHANGED
|
@@ -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)
|
|
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
|
|
634
|
-
*
|
|
635
|
-
*
|
|
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)
|
|
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;
|