borgmcp 2.4.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/dist/assimilate-cmd.d.ts +1 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +58 -30
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +2 -0
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.js +9 -3
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +7 -4
- package/dist/cli-help.js.map +1 -1
- package/dist/codex-launch.d.ts.map +1 -1
- package/dist/codex-launch.js +2 -1
- package/dist/codex-launch.js.map +1 -1
- package/dist/first-run-server.d.ts +31 -0
- package/dist/first-run-server.d.ts.map +1 -0
- package/dist/first-run-server.js +117 -0
- package/dist/first-run-server.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -27
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +4 -8
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +22 -15
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-drone.d.ts +10 -5
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +241 -48
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-wake-copy.d.ts +2 -0
- package/dist/opencode-wake-copy.d.ts.map +1 -0
- package/dist/opencode-wake-copy.js +8 -0
- package/dist/opencode-wake-copy.js.map +1 -0
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +6 -4
- package/dist/regen-format.js.map +1 -1
- package/dist/repository-cube-init.d.ts +2 -0
- package/dist/repository-cube-init.d.ts.map +1 -1
- package/dist/repository-cube-init.js +14 -3
- package/dist/repository-cube-init.js.map +1 -1
- package/dist/server-facade.d.ts +2 -0
- package/dist/server-facade.d.ts.map +1 -1
- package/dist/server-facade.js +25 -3
- package/dist/server-facade.js.map +1 -1
- package/dist/server-handshake.d.ts +1 -1
- package/dist/setup.js +10 -0
- package/dist/setup.js.map +1 -1
- package/dist/stream-status.d.ts +9 -8
- package/dist/stream-status.d.ts.map +1 -1
- package/dist/stream-status.js +67 -17
- package/dist/stream-status.js.map +1 -1
- package/dist/update-cmd.d.ts +11 -1
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +69 -5
- package/dist/update-cmd.js.map +1 -1
- package/dist/wake-path-health.d.ts +27 -0
- package/dist/wake-path-health.d.ts.map +1 -0
- package/dist/wake-path-health.js +51 -0
- package/dist/wake-path-health.js.map +1 -0
- package/docs/EXTRACTION_PROVENANCE.md +8 -8
- package/docs/LOCAL_SERVER.md +14 -5
- package/docs/RELEASING.md +16 -6
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +66 -27
- package/src/assimilate-deps.ts +9 -0
- package/src/claude.ts +10 -3
- package/src/cli-help.ts +7 -4
- package/src/codex-launch.ts +2 -1
- package/src/first-run-server.ts +178 -0
- package/src/index.ts +24 -28
- package/src/log-stream.ts +36 -25
- package/src/opencode-drone.ts +295 -47
- package/src/opencode-wake-copy.ts +8 -0
- package/src/regen-format.ts +6 -6
- package/src/repository-cube-init.ts +16 -3
- package/src/server-facade.ts +34 -3
- package/src/server-handshake.ts +1 -1
- package/src/setup.ts +11 -0
- package/src/stream-status.ts +85 -21
- package/src/update-cmd.ts +89 -6
- package/src/wake-path-health.ts +85 -0
package/src/log-stream.ts
CHANGED
|
@@ -120,9 +120,13 @@ function isProcessAlive(pid: number): boolean {
|
|
|
120
120
|
* startup (from index.ts) once the opencode drone module is initialized.
|
|
121
121
|
* Used by defaultDeps when no explicit injectOpenCode is supplied.
|
|
122
122
|
*/
|
|
123
|
-
let _moduleInjectOpenCode:
|
|
123
|
+
let _moduleInjectOpenCode:
|
|
124
|
+
| ((text: string, entryId: string, allowSubmit: boolean) => Promise<boolean>)
|
|
125
|
+
| undefined;
|
|
124
126
|
|
|
125
|
-
export function setModuleInjectOpenCode(
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
822
|
-
)
|
|
826
|
+
await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line)
|
|
827
|
+
);
|
|
828
|
+
if (alreadyPersisted) {
|
|
829
|
+
// Replay still re-enters the OpenCode delivery queue with the same entry
|
|
830
|
+
// ID. The queue confirms/deduplicates it by stable OpenCode message ID.
|
|
831
|
+
await injectOpenCode(line, ev.id, false);
|
|
823
832
|
markEventPersisted(ev.id, ev.data?.created_at ?? '');
|
|
824
833
|
return 'persisted-skip';
|
|
825
834
|
}
|
|
826
|
-
//
|
|
827
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/opencode-drone.ts
CHANGED
|
@@ -19,6 +19,13 @@ interface OpenCodeDroneState {
|
|
|
19
19
|
cubeName: string;
|
|
20
20
|
connected: boolean;
|
|
21
21
|
totalEntriesInjected: number;
|
|
22
|
+
totalEntriesRetried: number;
|
|
23
|
+
deliveryQueue: OpenCodeDelivery[];
|
|
24
|
+
activeDeliveries: Map<string, OpenCodeDelivery>;
|
|
25
|
+
deliveredEntries: Map<string, string>;
|
|
26
|
+
unconfirmedEntries: Map<string, string>;
|
|
27
|
+
failedEntries: Map<string, string>;
|
|
28
|
+
processingDeliveries: boolean;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
let state: OpenCodeDroneState | null = null;
|
|
@@ -41,6 +48,7 @@ interface OCSession {
|
|
|
41
48
|
|
|
42
49
|
interface OCMessage {
|
|
43
50
|
info?: {
|
|
51
|
+
id?: string;
|
|
44
52
|
role?: string;
|
|
45
53
|
time?: { created?: number };
|
|
46
54
|
};
|
|
@@ -50,6 +58,27 @@ interface OCMessage {
|
|
|
50
58
|
}>;
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
export type OpenCodeDeliveryState =
|
|
62
|
+
| 'queued'
|
|
63
|
+
| 'delivered-unconfirmed'
|
|
64
|
+
| 'retried'
|
|
65
|
+
| 'failed';
|
|
66
|
+
|
|
67
|
+
interface OpenCodeDelivery {
|
|
68
|
+
entryId: string;
|
|
69
|
+
text: string;
|
|
70
|
+
messageId: string;
|
|
71
|
+
allowSubmit: boolean;
|
|
72
|
+
state: Exclude<OpenCodeDeliveryState, 'failed'>;
|
|
73
|
+
resolve: (delivered: boolean) => void;
|
|
74
|
+
promise: Promise<boolean>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type OpenCodeDeliveryOutcome = 'delivered' | 'delivered-unconfirmed' | 'failed';
|
|
78
|
+
|
|
79
|
+
const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const;
|
|
80
|
+
const OPEN_CODE_DELIVERY_HISTORY_LIMIT = 256;
|
|
81
|
+
|
|
53
82
|
interface SessionBinding {
|
|
54
83
|
version: 2;
|
|
55
84
|
sessionId: string;
|
|
@@ -89,7 +118,18 @@ export function createOpenCodeLaunchKickoff(
|
|
|
89
118
|
|
|
90
119
|
const bindingPathsForTests = new Set<string>();
|
|
91
120
|
|
|
121
|
+
function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
|
|
122
|
+
if (!current) return;
|
|
123
|
+
current.connected = false;
|
|
124
|
+
for (const delivery of current.activeDeliveries.values()) {
|
|
125
|
+
delivery.resolve(false);
|
|
126
|
+
}
|
|
127
|
+
current.activeDeliveries.clear();
|
|
128
|
+
current.deliveryQueue.length = 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
92
131
|
export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
|
|
132
|
+
abandonOpenCodeDeliveries(state);
|
|
93
133
|
state = {
|
|
94
134
|
serverUrl: deps.serverUrl,
|
|
95
135
|
sessionId: null,
|
|
@@ -100,6 +140,13 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
|
|
|
100
140
|
cubeName: deps.cubeName,
|
|
101
141
|
connected: true,
|
|
102
142
|
totalEntriesInjected: 0,
|
|
143
|
+
totalEntriesRetried: 0,
|
|
144
|
+
deliveryQueue: [],
|
|
145
|
+
activeDeliveries: new Map(),
|
|
146
|
+
deliveredEntries: new Map(),
|
|
147
|
+
unconfirmedEntries: new Map(),
|
|
148
|
+
failedEntries: new Map(),
|
|
149
|
+
processingDeliveries: false,
|
|
103
150
|
};
|
|
104
151
|
log(`connected url=${deps.serverUrl} dir=${deps.directory}`);
|
|
105
152
|
}
|
|
@@ -153,13 +200,10 @@ async function listSessions(): Promise<OCSession[]> {
|
|
|
153
200
|
}
|
|
154
201
|
|
|
155
202
|
async function getSession(id: string): Promise<OCSession | null> {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
} catch {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
203
|
+
const { status, body } = await rawGet(`/session/${id}`);
|
|
204
|
+
if (status === 404) return null;
|
|
205
|
+
if (status !== 200) throw new Error(`OpenCode session request failed (${status})`);
|
|
206
|
+
return JSON.parse(body);
|
|
163
207
|
}
|
|
164
208
|
|
|
165
209
|
async function listSessionMessages(id: string): Promise<OCMessage[]> {
|
|
@@ -168,9 +212,27 @@ async function listSessionMessages(id: string): Promise<OCMessage[]> {
|
|
|
168
212
|
return JSON.parse(body);
|
|
169
213
|
}
|
|
170
214
|
|
|
171
|
-
async function
|
|
215
|
+
async function getSessionMessage(
|
|
216
|
+
sessionId: string,
|
|
217
|
+
messageId: string,
|
|
218
|
+
): Promise<'found' | 'missing'> {
|
|
219
|
+
const { status, body } = await rawGet(
|
|
220
|
+
`/session/${sessionId}/message/${encodeURIComponent(messageId)}`,
|
|
221
|
+
);
|
|
222
|
+
if (status === 404) return 'missing';
|
|
223
|
+
if (status !== 200) {
|
|
224
|
+
throw new Error(`OpenCode message request failed (${status})`);
|
|
225
|
+
}
|
|
226
|
+
const message = JSON.parse(body) as OCMessage;
|
|
227
|
+
if (message.info?.id !== messageId || message.info.role !== 'user') {
|
|
228
|
+
throw new Error('OpenCode returned the wrong injected message');
|
|
229
|
+
}
|
|
230
|
+
return 'found';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function promptSession(id: string, bodyObj: Record<string, unknown>): Promise<number> {
|
|
172
234
|
const { status } = await rawPost(`/session/${id}/prompt_async`, bodyObj);
|
|
173
|
-
return status
|
|
235
|
+
return status;
|
|
174
236
|
}
|
|
175
237
|
|
|
176
238
|
// ---------------------------------------------------------------------------
|
|
@@ -381,11 +443,156 @@ async function resolveInjectionSession(): Promise<OCSession | null> {
|
|
|
381
443
|
return bound;
|
|
382
444
|
}
|
|
383
445
|
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
446
|
+
function openCodeMessageId(entryId: string): string {
|
|
447
|
+
const digest = createHash('sha256').update(entryId).digest('hex');
|
|
448
|
+
return `msg_borg_${digest}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function rememberBounded(
|
|
452
|
+
entries: Map<string, string>,
|
|
453
|
+
entryId: string,
|
|
454
|
+
text: string,
|
|
455
|
+
): void {
|
|
456
|
+
entries.delete(entryId);
|
|
457
|
+
entries.set(entryId, text);
|
|
458
|
+
while (entries.size > OPEN_CODE_DELIVERY_HISTORY_LIMIT) {
|
|
459
|
+
const oldest = entries.keys().next().value;
|
|
460
|
+
if (typeof oldest !== 'string') break;
|
|
461
|
+
entries.delete(oldest);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function waitForDeliveryRetry(attempt: number): Promise<void> {
|
|
466
|
+
const delay = OPEN_CODE_DELIVERY_RETRY_DELAYS_MS[attempt] ?? 0;
|
|
467
|
+
return delay > 0
|
|
468
|
+
? new Promise((resolve) => setTimeout(resolve, delay))
|
|
469
|
+
: Promise.resolve();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function deliverOpenCodeEntry(
|
|
473
|
+
owner: OpenCodeDroneState,
|
|
474
|
+
delivery: OpenCodeDelivery,
|
|
475
|
+
): Promise<OpenCodeDeliveryOutcome> {
|
|
476
|
+
let target: OCSession | null = null;
|
|
477
|
+
|
|
478
|
+
// Before the one allowed POST, retries are safe: no submission has happened.
|
|
479
|
+
// A replayed inbox entry sets allowSubmit=false, so it can only confirm an
|
|
480
|
+
// earlier submission and can never manufacture a second prompt.
|
|
481
|
+
for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
|
|
482
|
+
if (state !== owner || !owner.connected) return 'failed';
|
|
483
|
+
if (attempt > 0) {
|
|
484
|
+
delivery.state = 'retried';
|
|
485
|
+
owner.totalEntriesRetried++;
|
|
486
|
+
await waitForDeliveryRetry(attempt);
|
|
487
|
+
if (state !== owner || !owner.connected) return 'failed';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!target) {
|
|
491
|
+
try {
|
|
492
|
+
target = await resolveInjectionSession();
|
|
493
|
+
} catch (err) {
|
|
494
|
+
log(`entry ${delivery.entryId} target unavailable: ${err}`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (!target) return 'failed';
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
if (await getSessionMessage(target.id, delivery.messageId) === 'found') {
|
|
502
|
+
return 'delivered';
|
|
503
|
+
}
|
|
504
|
+
} catch (err) {
|
|
505
|
+
log(`entry ${delivery.entryId} confirmation unavailable: ${err}`);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (!delivery.allowSubmit) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// OpenCode does not deduplicate repeated prompt_async calls by messageID:
|
|
514
|
+
// they append duplicate parts. Submit at most once, then only poll the
|
|
515
|
+
// exact message. A transport failure is ambiguous and follows the same
|
|
516
|
+
// confirmation-only path.
|
|
517
|
+
let status: number | null = null;
|
|
518
|
+
try {
|
|
519
|
+
status = await promptSession(target.id, {
|
|
520
|
+
messageID: delivery.messageId,
|
|
521
|
+
parts: [{ type: 'text', text: delivery.text }],
|
|
522
|
+
});
|
|
523
|
+
} catch (err) {
|
|
524
|
+
log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
delivery.state = 'delivered-unconfirmed';
|
|
528
|
+
if (status !== null && status !== 200 && status !== 204) {
|
|
529
|
+
if (status === 404) clearBinding();
|
|
530
|
+
return 'failed';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for (
|
|
534
|
+
let confirmationAttempt = 0;
|
|
535
|
+
confirmationAttempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length;
|
|
536
|
+
confirmationAttempt++
|
|
537
|
+
) {
|
|
538
|
+
if (confirmationAttempt > 0) {
|
|
539
|
+
delivery.state = 'retried';
|
|
540
|
+
owner.totalEntriesRetried++;
|
|
541
|
+
await waitForDeliveryRetry(confirmationAttempt);
|
|
542
|
+
if (state !== owner || !owner.connected) return 'delivered-unconfirmed';
|
|
543
|
+
delivery.state = 'delivered-unconfirmed';
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
if (await getSessionMessage(target.id, delivery.messageId) === 'found') {
|
|
547
|
+
return 'delivered';
|
|
548
|
+
}
|
|
549
|
+
} catch (err) {
|
|
550
|
+
log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return 'delivered-unconfirmed';
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// A replay begins from a durable inbox record but cannot know whether the
|
|
558
|
+
// prior process stopped before or after submission. Missing confirmation
|
|
559
|
+
// therefore remains unknown, not a definite delivery failure.
|
|
560
|
+
return delivery.allowSubmit ? 'failed' : 'delivered-unconfirmed';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<void> {
|
|
564
|
+
if (owner.processingDeliveries) return;
|
|
565
|
+
owner.processingDeliveries = true;
|
|
566
|
+
try {
|
|
567
|
+
while (state === owner && owner.deliveryQueue.length > 0) {
|
|
568
|
+
const delivery = owner.deliveryQueue.shift()!;
|
|
569
|
+
let outcome: OpenCodeDeliveryOutcome = 'failed';
|
|
570
|
+
try {
|
|
571
|
+
outcome = await deliverOpenCodeEntry(owner, delivery);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
log(`entry ${delivery.entryId} delivery error: ${err}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
owner.activeDeliveries.delete(delivery.entryId);
|
|
577
|
+
if (outcome === 'delivered') {
|
|
578
|
+
owner.unconfirmedEntries.delete(delivery.entryId);
|
|
579
|
+
owner.failedEntries.delete(delivery.entryId);
|
|
580
|
+
rememberBounded(owner.deliveredEntries, delivery.entryId, delivery.text);
|
|
581
|
+
owner.totalEntriesInjected++;
|
|
582
|
+
delivery.resolve(true);
|
|
583
|
+
} else if (outcome === 'delivered-unconfirmed') {
|
|
584
|
+
owner.failedEntries.delete(delivery.entryId);
|
|
585
|
+
rememberBounded(owner.unconfirmedEntries, delivery.entryId, delivery.text);
|
|
586
|
+
delivery.resolve(true);
|
|
587
|
+
} else {
|
|
588
|
+
owner.unconfirmedEntries.delete(delivery.entryId);
|
|
589
|
+
rememberBounded(owner.failedEntries, delivery.entryId, delivery.text);
|
|
590
|
+
delivery.resolve(false);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} finally {
|
|
594
|
+
owner.processingDeliveries = false;
|
|
595
|
+
}
|
|
389
596
|
}
|
|
390
597
|
|
|
391
598
|
// ---------------------------------------------------------------------------
|
|
@@ -434,42 +641,67 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
|
|
|
434
641
|
}
|
|
435
642
|
|
|
436
643
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
644
|
+
* Queue one durable inbox entry for delivery into the bound OpenCode session.
|
|
645
|
+
* The SSE entry ID becomes a stable OpenCode message ID, so retries and replay
|
|
646
|
+
* can confirm an earlier ambiguous submission without running it twice.
|
|
439
647
|
*/
|
|
440
|
-
export
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
648
|
+
export function injectOpenCodeEntry(
|
|
649
|
+
text: string,
|
|
650
|
+
entryId: string = createHash('sha256').update(text).digest('hex'),
|
|
651
|
+
allowSubmit: boolean = true,
|
|
652
|
+
): Promise<boolean> {
|
|
653
|
+
const owner = state;
|
|
654
|
+
if (!owner?.connected) return Promise.resolve(false);
|
|
655
|
+
|
|
656
|
+
const deliveredText = owner.deliveredEntries.get(entryId);
|
|
657
|
+
if (deliveredText !== undefined) {
|
|
658
|
+
if (deliveredText !== text) {
|
|
659
|
+
log(`entry ${entryId} replay text mismatch`);
|
|
660
|
+
rememberBounded(owner.failedEntries, entryId, text);
|
|
661
|
+
return Promise.resolve(false);
|
|
662
|
+
}
|
|
663
|
+
return Promise.resolve(true);
|
|
664
|
+
}
|
|
446
665
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
return
|
|
666
|
+
const unconfirmedText = owner.unconfirmedEntries.get(entryId);
|
|
667
|
+
if (unconfirmedText !== undefined) {
|
|
668
|
+
if (unconfirmedText !== text) {
|
|
669
|
+
log(`entry ${entryId} unconfirmed replay text mismatch`);
|
|
670
|
+
rememberBounded(owner.failedEntries, entryId, text);
|
|
671
|
+
return Promise.resolve(false);
|
|
453
672
|
}
|
|
673
|
+
return Promise.resolve(true);
|
|
674
|
+
}
|
|
454
675
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (!replacement) return false;
|
|
462
|
-
if (await promptSession(replacement.id, body)) {
|
|
463
|
-
state.totalEntriesInjected++;
|
|
464
|
-
return true;
|
|
676
|
+
const active = owner.activeDeliveries.get(entryId);
|
|
677
|
+
if (active) {
|
|
678
|
+
if (active.text !== text) {
|
|
679
|
+
log(`entry ${entryId} active text mismatch`);
|
|
680
|
+
rememberBounded(owner.failedEntries, entryId, text);
|
|
681
|
+
return Promise.resolve(false);
|
|
465
682
|
}
|
|
466
|
-
|
|
467
|
-
return false;
|
|
468
|
-
} catch (err) {
|
|
469
|
-
log(`entry error: ${err}`);
|
|
470
|
-
clearBinding();
|
|
471
|
-
return false;
|
|
683
|
+
return active.promise;
|
|
472
684
|
}
|
|
685
|
+
|
|
686
|
+
let resolveDelivery!: (delivered: boolean) => void;
|
|
687
|
+
const promise = new Promise<boolean>((resolve) => {
|
|
688
|
+
resolveDelivery = resolve;
|
|
689
|
+
});
|
|
690
|
+
const delivery: OpenCodeDelivery = {
|
|
691
|
+
entryId,
|
|
692
|
+
text,
|
|
693
|
+
messageId: openCodeMessageId(entryId),
|
|
694
|
+
allowSubmit,
|
|
695
|
+
state: 'queued',
|
|
696
|
+
resolve: resolveDelivery,
|
|
697
|
+
promise,
|
|
698
|
+
};
|
|
699
|
+
owner.unconfirmedEntries.delete(entryId);
|
|
700
|
+
owner.failedEntries.delete(entryId);
|
|
701
|
+
owner.activeDeliveries.set(entryId, delivery);
|
|
702
|
+
owner.deliveryQueue.push(delivery);
|
|
703
|
+
void processOpenCodeDeliveries(owner);
|
|
704
|
+
return promise;
|
|
473
705
|
}
|
|
474
706
|
|
|
475
707
|
export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
|
|
@@ -483,24 +715,39 @@ export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
|
|
|
483
715
|
clearBinding();
|
|
484
716
|
return false;
|
|
485
717
|
} catch {
|
|
486
|
-
clearBinding();
|
|
487
718
|
return false;
|
|
488
719
|
}
|
|
489
720
|
}
|
|
490
721
|
|
|
491
722
|
export function disconnectOpenCodeDrone(): void {
|
|
723
|
+
abandonOpenCodeDeliveries(state);
|
|
492
724
|
state = null;
|
|
493
725
|
}
|
|
494
726
|
|
|
495
|
-
export
|
|
727
|
+
export interface OpenCodeConnectionState {
|
|
496
728
|
connected: boolean;
|
|
497
729
|
sessionId: string | null;
|
|
498
730
|
totalEntriesInjected: number;
|
|
499
|
-
|
|
731
|
+
totalEntriesRetried: number;
|
|
732
|
+
deliveryStates: Record<OpenCodeDeliveryState, number>;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function getOpenCodeConnectionState(): OpenCodeConnectionState {
|
|
736
|
+
const deliveryStates: Record<OpenCodeDeliveryState, number> = {
|
|
737
|
+
queued: 0,
|
|
738
|
+
'delivered-unconfirmed': state?.unconfirmedEntries.size ?? 0,
|
|
739
|
+
retried: 0,
|
|
740
|
+
failed: state?.failedEntries.size ?? 0,
|
|
741
|
+
};
|
|
742
|
+
for (const delivery of state?.activeDeliveries.values() ?? []) {
|
|
743
|
+
deliveryStates[delivery.state]++;
|
|
744
|
+
}
|
|
500
745
|
return {
|
|
501
746
|
connected: state?.connected ?? false,
|
|
502
747
|
sessionId: state?.sessionId ?? null,
|
|
503
748
|
totalEntriesInjected: state?.totalEntriesInjected ?? 0,
|
|
749
|
+
totalEntriesRetried: state?.totalEntriesRetried ?? 0,
|
|
750
|
+
deliveryStates,
|
|
504
751
|
};
|
|
505
752
|
}
|
|
506
753
|
|
|
@@ -516,6 +763,7 @@ export function computeOpenCodePort(droneId: string, base: number = 14096): numb
|
|
|
516
763
|
|
|
517
764
|
/** Test-only cleanup for module state and the local cross-process binding. */
|
|
518
765
|
export function __resetOpenCodeDroneForTests(): void {
|
|
766
|
+
abandonOpenCodeDeliveries(state);
|
|
519
767
|
state = null;
|
|
520
768
|
for (const path of bindingPathsForTests) {
|
|
521
769
|
try {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const OPENCODE_WAKE_PATH_GUIDANCE =
|
|
2
|
+
'OpenCode wakes through HTTP entry injection into its session through the local HTTP API. ' +
|
|
3
|
+
'Borg writes each entry to the durable inbox before one injection attempt and ' +
|
|
4
|
+
'confirms delivery by exact message ID; no inbox Monitor or secondary wake loop is used. ' +
|
|
5
|
+
'If injection is rejected or cannot be ' +
|
|
6
|
+
'confirmed, the durable entry remains available and `borg_stream-status` reports ' +
|
|
7
|
+
'it as failed or delivered-unconfirmed; run `borg_read-log unread_only=true` and ' +
|
|
8
|
+
'drain the unread log to recover.';
|
package/src/regen-format.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from './roster-render.js';
|
|
19
19
|
import { shellEscape } from './shell-escape.js';
|
|
20
20
|
import { resolveInboxMonitorPath } from './self-path.js';
|
|
21
|
+
import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Extract the SessionStart `source` from a Claude Code hook payload (gh#926).
|
|
@@ -85,11 +86,7 @@ export function wakePathArming(
|
|
|
85
86
|
].join(' ');
|
|
86
87
|
}
|
|
87
88
|
if (agentKind === 'opencode') {
|
|
88
|
-
return
|
|
89
|
-
'Wake path: OpenCode wakes via SDK-driven entry injection into the TUI session — ' +
|
|
90
|
-
'there is no tail-Monitor or `/loop` heartbeat to arm. Cube posts are delivered ' +
|
|
91
|
-
'directly into your active session. Use \`borg_regen\` at any time for fresh context.'
|
|
92
|
-
);
|
|
89
|
+
return OPENCODE_WAKE_PATH_GUIDANCE;
|
|
93
90
|
}
|
|
94
91
|
// gh#client#18: use absolute path to THIS installation's borg-inbox-monitor
|
|
95
92
|
// so the orientation command always resolves to the same version as the
|
|
@@ -249,7 +246,9 @@ You're a Drone in a Cube. Coordinate with other drones through the activity log.
|
|
|
249
246
|
2. Apply your role's conventions to each entry. Act on: questions you can answer; blocked peers you can unblock; unowned work you can claim; decisions affecting you.
|
|
250
247
|
3. Actionable signal → act + post the convention. Don't wait to be asked.
|
|
251
248
|
4. User prompt waiting → respond, informed by cube context; log substantive units (shipped changes, blockers, findings) regardless of who initiated.
|
|
252
|
-
5.
|
|
249
|
+
5. Holding unfinished assigned work? Resume it now, in this turn. An acknowledgement
|
|
250
|
+
or a status reply must never be the last action of a turn while work is outstanding.
|
|
251
|
+
6. Nothing actionable, no prompt, and no work of your own outstanding → done; wait for next wake.
|
|
253
252
|
|
|
254
253
|
**On a \`<task-notification>\` wake:** the payload is a truncatable preview; the full entry is in the DB. Drain: \`borg_read-log unread_only=true limit=20\`, repeat until \`behind_by=0\`. Do NOT triage with \`since=<notification timestamp>\` (strict-after — skips the boundary entry) or a bare window (skips older-unread during bursts).
|
|
255
254
|
${arrivalInstruction}
|
|
@@ -261,6 +260,7 @@ ${arrivalInstruction}
|
|
|
261
260
|
**When stuck:** post your blocker per your role's conventions, continue other work. Escalation is per your role detail, not by stalling.
|
|
262
261
|
|
|
263
262
|
**Anti-passive (lane idle = no work routed to you, no actionable signal in the log):**
|
|
263
|
+
**Completion contract:** A work item is finished when you post its completion or blocked signal, not when you answer a question. If you hold unfinished assigned work, your lane is not idle; resume it in this turn.
|
|
264
264
|
- If your work arrives via dispatch / a work queue: when your lane goes idle, post your role's availability signal (capacity clean, awaiting next assignment from your coordinating role) — once per idle period, don't spam. No assignment in ~15 min → ping your coordinating role (capacity available since <time>; any queue item to pick up?).
|
|
265
265
|
- If your work is SELF-DIRECTED (not dispatch-driven): do NOT post an availability signal — proactively surface lane-substantive work per your role (reviews, audits, proposals, coherence / quality sweeps on relevant in-flight work).
|
|
266
266
|
- Route work-asks through your cube's coordinating role, never directly to the human Queen.
|
|
@@ -46,6 +46,8 @@ export interface RepositoryCubeInitDeps {
|
|
|
46
46
|
isTTY(): boolean;
|
|
47
47
|
prompt(message: string): Promise<string>;
|
|
48
48
|
write(text: string): void;
|
|
49
|
+
writeResult?(text: string): void;
|
|
50
|
+
useColor?(): boolean;
|
|
49
51
|
getIdentity(context: GitRepositoryContext): Promise<CreateCubeRepository>;
|
|
50
52
|
getAssociation(repository: CreateCubeRepository): Promise<RepositoryAssociation | null>;
|
|
51
53
|
saveAssociation(repository: CreateCubeRepository, association: RepositoryAssociation): Promise<void>;
|
|
@@ -132,8 +134,16 @@ function renderResult(
|
|
|
132
134
|
adopted?: boolean;
|
|
133
135
|
},
|
|
134
136
|
): void {
|
|
137
|
+
const resultWriter = deps.writeResult ?? deps.write;
|
|
138
|
+
const heading = input.adopted
|
|
139
|
+
? 'Existing cube associated with this repository.'
|
|
140
|
+
: input.existing
|
|
141
|
+
? 'Cube already initialized.'
|
|
142
|
+
: 'Cube created.';
|
|
135
143
|
const lines = [
|
|
136
|
-
input.
|
|
144
|
+
input.mode === 'cube-init'
|
|
145
|
+
? `${deps.useColor?.() ? '\u001b[32m✓\u001b[0m' : '✓'} ${heading}`
|
|
146
|
+
: heading,
|
|
137
147
|
` Name: ${input.response.name}`,
|
|
138
148
|
` Template: ${presentation(input.response.template).label}`,
|
|
139
149
|
` Repository: ${input.root}`,
|
|
@@ -150,7 +160,7 @@ function renderResult(
|
|
|
150
160
|
} else {
|
|
151
161
|
lines.push('Continuing with role and seat setup...');
|
|
152
162
|
}
|
|
153
|
-
|
|
163
|
+
resultWriter(`${lines.join('\n')}\n`);
|
|
154
164
|
}
|
|
155
165
|
|
|
156
166
|
export async function initializeRepositoryCube(input: {
|
|
@@ -300,8 +310,11 @@ export async function initializeRepositoryCube(input: {
|
|
|
300
310
|
if (proposedAdoption) return proposedAdoption;
|
|
301
311
|
|
|
302
312
|
if (input.canCreate === false) {
|
|
313
|
+
const retryCommand = input.mode === 'cube-init'
|
|
314
|
+
? `borg server cube init --host ${shellEscape(input.serverOrigin)}`
|
|
315
|
+
: `borg assimilate --host ${shellEscape(input.serverOrigin)}`;
|
|
303
316
|
deps.write(
|
|
304
|
-
`This enrolled client cannot create a cube on ${input.serverOrigin}. Ask the server operator to grant access to a cube, then rerun
|
|
317
|
+
`This enrolled client cannot create a cube on ${input.serverOrigin}. Ask the server operator to grant access to a cube, then rerun ${retryCommand}.\n`,
|
|
305
318
|
);
|
|
306
319
|
return { kind: 'stop', code: 1 };
|
|
307
320
|
}
|