borgmcp 2.5.0 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2 -2
- package/dist/codex-launch.d.ts.map +1 -1
- package/dist/codex-launch.js +7 -7
- 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 +11 -5
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +236 -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 +4 -6
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +10 -14
- 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/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 +68 -18
- package/dist/stream-status.js.map +1 -1
- package/dist/tool-manifest.js +2 -2
- package/dist/tool-manifest.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 +68 -4
- 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 +3 -3
- package/docs/LOCAL_SERVER.md +12 -3
- package/docs/RELEASING.md +11 -1
- package/package.json +1 -1
- 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 +7 -7
- 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 +283 -47
- package/src/opencode-wake-copy.ts +8 -0
- package/src/regen-format.ts +10 -16
- package/src/repository-cube-init.ts +16 -3
- package/src/server-facade.ts +34 -3
- package/src/setup.ts +11 -0
- package/src/stream-status.ts +86 -22
- package/src/tool-manifest.ts +2 -2
- package/src/update-cmd.ts +88 -5
- package/src/wake-path-health.ts +85 -0
package/src/index.ts
CHANGED
|
@@ -90,10 +90,10 @@ import { filterToolsForRole } from './tool-scope.js';
|
|
|
90
90
|
import { getPackageVersion, getOnDiskVersion, handleVersionFlag } from './version.js';
|
|
91
91
|
import {
|
|
92
92
|
renderStreamStatus,
|
|
93
|
-
checkInboxMonitorHealthy,
|
|
94
93
|
formatWakePathPrefix,
|
|
95
94
|
shouldShowWakePathWarning,
|
|
96
95
|
} from './stream-status.js';
|
|
96
|
+
import { inspectWakePath } from './wake-path-health.js';
|
|
97
97
|
import {
|
|
98
98
|
RUNTIME_METADATA_ADVISORY,
|
|
99
99
|
renderRoster,
|
|
@@ -128,7 +128,6 @@ import {
|
|
|
128
128
|
connectOpenCodeDrone,
|
|
129
129
|
injectOpenCodeEntry,
|
|
130
130
|
computeOpenCodePort,
|
|
131
|
-
getOpenCodeConnectionState,
|
|
132
131
|
} from './opencode-drone.js';
|
|
133
132
|
import { installBorgPlugin } from './opencode-plugin.js';
|
|
134
133
|
import { setModuleInjectOpenCode } from './log-stream.js';
|
|
@@ -401,31 +400,28 @@ export async function main() {
|
|
|
401
400
|
await refreshActiveCubeMetadata(freshActive);
|
|
402
401
|
}
|
|
403
402
|
|
|
404
|
-
// Wake-path self-heal (gh#43): SSE delivery to the inbox
|
|
405
|
-
// is
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// boundary), the drone misses every incoming entry until the
|
|
409
|
-
// /loop fallback heartbeat. Because regen runs on every /loop
|
|
410
|
-
// iteration, surfacing the breakage here gives self-healing at
|
|
411
|
-
// worst-case latency = the heartbeat interval. Mirrors the
|
|
412
|
-
// State-5 self-arm instruction in stream-status.ts.
|
|
403
|
+
// Wake-path self-heal (gh#43): SSE delivery to the durable inbox
|
|
404
|
+
// is distinct from the runtime-specific mechanism that wakes the
|
|
405
|
+
// agent. Inspect that real mechanism for Claude, Codex, or OpenCode
|
|
406
|
+
// and surface a runtime-specific recovery prefix when it is broken.
|
|
413
407
|
const streamStatus = getStreamStatus();
|
|
414
408
|
const inboxPath = inboxPathForDrone(freshActive.cubeId, freshActive.droneId);
|
|
415
409
|
const monitorStateRoot = monitorStateRootForWorktree(findProjectRoot());
|
|
416
|
-
// Non-Claude CLIs do not use the Claude inbox Monitor. Keep the
|
|
417
|
-
// agent CLI distinction independent from whether Codex's optional
|
|
418
|
-
// remote-wake transport is currently armed.
|
|
419
410
|
const agentKind = resolveSessionAgentKind();
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
:
|
|
411
|
+
const wakePath = await inspectWakePath({
|
|
412
|
+
agentKind,
|
|
413
|
+
active: freshActive,
|
|
414
|
+
inboxPath,
|
|
415
|
+
monitorStateRoot,
|
|
416
|
+
});
|
|
417
|
+
const inboxMonitorHealthy = wakePath.healthy;
|
|
423
418
|
const prefix = shouldShowWakePathWarning(streamStatus, inboxMonitorHealthy)
|
|
424
419
|
? formatWakePathPrefix({
|
|
425
420
|
inboxPath,
|
|
426
421
|
monitorStateRoot,
|
|
427
422
|
droneLabel: regenWakePathDroneLabel(displayedResult, displayIdentity.droneLabel),
|
|
428
423
|
cubeName: displayIdentity.cubeName,
|
|
424
|
+
wakePath,
|
|
429
425
|
})
|
|
430
426
|
: '';
|
|
431
427
|
|
|
@@ -686,9 +682,9 @@ export async function main() {
|
|
|
686
682
|
// Probe the in-process SSE consumer state. Does NOT require
|
|
687
683
|
// an active cube — if the consumer hasn't started or is
|
|
688
684
|
// between cubes, the snapshot still reports current values.
|
|
689
|
-
// Also probes wake-path completeness
|
|
690
|
-
//
|
|
691
|
-
//
|
|
685
|
+
// Also probes runtime-specific wake-path completeness: Claude's
|
|
686
|
+
// inbox Monitor, Codex's remote-control bridge, or OpenCode's
|
|
687
|
+
// durable injection delivery state.
|
|
692
688
|
const status = getStreamStatus();
|
|
693
689
|
const active = await getActiveCube();
|
|
694
690
|
const inboxPath = active
|
|
@@ -697,14 +693,13 @@ export async function main() {
|
|
|
697
693
|
const monitorStateRoot = active
|
|
698
694
|
? monitorStateRootForWorktree(findProjectRoot())
|
|
699
695
|
: null;
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
: null;
|
|
696
|
+
const wakePath = await inspectWakePath({
|
|
697
|
+
agentKind: resolveSessionAgentKind(),
|
|
698
|
+
active,
|
|
699
|
+
inboxPath,
|
|
700
|
+
monitorStateRoot,
|
|
701
|
+
});
|
|
702
|
+
const inboxMonitorHealthy = wakePath.healthy;
|
|
708
703
|
let silentInertWarning = '';
|
|
709
704
|
if (status.runLoopHealth === 'silent-inert') {
|
|
710
705
|
silentInertWarning = '## ⚠ SSE stream loop silent-inert — run /mcp and reconnect to restart\n\n' +
|
|
@@ -713,6 +708,7 @@ export async function main() {
|
|
|
713
708
|
const text = renderStreamStatus({
|
|
714
709
|
status,
|
|
715
710
|
inboxMonitorHealthy,
|
|
711
|
+
wakePath,
|
|
716
712
|
inboxPath,
|
|
717
713
|
monitorStateRoot,
|
|
718
714
|
droneLabel: active?.droneLabel ?? null,
|
package/src/log-stream.ts
CHANGED
|
@@ -120,9 +120,13 @@ function isProcessAlive(pid: number): boolean {
|
|
|
120
120
|
* startup (from index.ts) once the opencode drone module is initialized.
|
|
121
121
|
* Used by defaultDeps when no explicit injectOpenCode is supplied.
|
|
122
122
|
*/
|
|
123
|
-
let _moduleInjectOpenCode:
|
|
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 the unique persisted entry text.
|
|
831
|
+
await injectOpenCode(line, ev.id, false);
|
|
823
832
|
markEventPersisted(ev.id, ev.data?.created_at ?? '');
|
|
824
833
|
return 'persisted-skip';
|
|
825
834
|
}
|
|
826
|
-
//
|
|
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,26 @@ interface OCMessage {
|
|
|
50
58
|
}>;
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
export type OpenCodeDeliveryState =
|
|
62
|
+
| 'queued'
|
|
63
|
+
| 'delivered-unconfirmed'
|
|
64
|
+
| 'retried'
|
|
65
|
+
| 'failed';
|
|
66
|
+
|
|
67
|
+
interface OpenCodeDelivery {
|
|
68
|
+
entryId: string;
|
|
69
|
+
text: string;
|
|
70
|
+
allowSubmit: boolean;
|
|
71
|
+
state: Exclude<OpenCodeDeliveryState, 'failed'>;
|
|
72
|
+
resolve: (delivered: boolean) => void;
|
|
73
|
+
promise: Promise<boolean>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type OpenCodeDeliveryOutcome = 'delivered' | 'delivered-unconfirmed' | 'failed';
|
|
77
|
+
|
|
78
|
+
const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const;
|
|
79
|
+
const OPEN_CODE_DELIVERY_HISTORY_LIMIT = 256;
|
|
80
|
+
|
|
53
81
|
interface SessionBinding {
|
|
54
82
|
version: 2;
|
|
55
83
|
sessionId: string;
|
|
@@ -89,7 +117,18 @@ export function createOpenCodeLaunchKickoff(
|
|
|
89
117
|
|
|
90
118
|
const bindingPathsForTests = new Set<string>();
|
|
91
119
|
|
|
120
|
+
function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
|
|
121
|
+
if (!current) return;
|
|
122
|
+
current.connected = false;
|
|
123
|
+
for (const delivery of current.activeDeliveries.values()) {
|
|
124
|
+
delivery.resolve(false);
|
|
125
|
+
}
|
|
126
|
+
current.activeDeliveries.clear();
|
|
127
|
+
current.deliveryQueue.length = 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
92
130
|
export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
|
|
131
|
+
abandonOpenCodeDeliveries(state);
|
|
93
132
|
state = {
|
|
94
133
|
serverUrl: deps.serverUrl,
|
|
95
134
|
sessionId: null,
|
|
@@ -100,6 +139,13 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
|
|
|
100
139
|
cubeName: deps.cubeName,
|
|
101
140
|
connected: true,
|
|
102
141
|
totalEntriesInjected: 0,
|
|
142
|
+
totalEntriesRetried: 0,
|
|
143
|
+
deliveryQueue: [],
|
|
144
|
+
activeDeliveries: new Map(),
|
|
145
|
+
deliveredEntries: new Map(),
|
|
146
|
+
unconfirmedEntries: new Map(),
|
|
147
|
+
failedEntries: new Map(),
|
|
148
|
+
processingDeliveries: false,
|
|
103
149
|
};
|
|
104
150
|
log(`connected url=${deps.serverUrl} dir=${deps.directory}`);
|
|
105
151
|
}
|
|
@@ -153,13 +199,10 @@ async function listSessions(): Promise<OCSession[]> {
|
|
|
153
199
|
}
|
|
154
200
|
|
|
155
201
|
async function getSession(id: string): Promise<OCSession | null> {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
} catch {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
202
|
+
const { status, body } = await rawGet(`/session/${id}`);
|
|
203
|
+
if (status === 404) return null;
|
|
204
|
+
if (status !== 200) throw new Error(`OpenCode session request failed (${status})`);
|
|
205
|
+
return JSON.parse(body);
|
|
163
206
|
}
|
|
164
207
|
|
|
165
208
|
async function listSessionMessages(id: string): Promise<OCMessage[]> {
|
|
@@ -168,9 +211,21 @@ async function listSessionMessages(id: string): Promise<OCMessage[]> {
|
|
|
168
211
|
return JSON.parse(body);
|
|
169
212
|
}
|
|
170
213
|
|
|
171
|
-
async function
|
|
214
|
+
async function findInjectedMessage(sessionId: string, text: string): Promise<string | null> {
|
|
215
|
+
const messages = await listSessionMessages(sessionId);
|
|
216
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
217
|
+
const message = messages[index];
|
|
218
|
+
if (message?.info?.role !== 'user' || typeof message.info.id !== 'string') continue;
|
|
219
|
+
if (message.parts?.some((part) => part.type === 'text' && part.text === text)) {
|
|
220
|
+
return message.info.id;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function promptSession(id: string, bodyObj: Record<string, unknown>): Promise<number> {
|
|
172
227
|
const { status } = await rawPost(`/session/${id}/prompt_async`, bodyObj);
|
|
173
|
-
return status
|
|
228
|
+
return status;
|
|
174
229
|
}
|
|
175
230
|
|
|
176
231
|
// ---------------------------------------------------------------------------
|
|
@@ -381,11 +436,151 @@ async function resolveInjectionSession(): Promise<OCSession | null> {
|
|
|
381
436
|
return bound;
|
|
382
437
|
}
|
|
383
438
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
439
|
+
function rememberBounded(
|
|
440
|
+
entries: Map<string, string>,
|
|
441
|
+
entryId: string,
|
|
442
|
+
text: string,
|
|
443
|
+
): void {
|
|
444
|
+
entries.delete(entryId);
|
|
445
|
+
entries.set(entryId, text);
|
|
446
|
+
while (entries.size > OPEN_CODE_DELIVERY_HISTORY_LIMIT) {
|
|
447
|
+
const oldest = entries.keys().next().value;
|
|
448
|
+
if (typeof oldest !== 'string') break;
|
|
449
|
+
entries.delete(oldest);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function waitForDeliveryRetry(attempt: number): Promise<void> {
|
|
454
|
+
const delay = OPEN_CODE_DELIVERY_RETRY_DELAYS_MS[attempt] ?? 0;
|
|
455
|
+
return delay > 0
|
|
456
|
+
? new Promise((resolve) => setTimeout(resolve, delay))
|
|
457
|
+
: Promise.resolve();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function deliverOpenCodeEntry(
|
|
461
|
+
owner: OpenCodeDroneState,
|
|
462
|
+
delivery: OpenCodeDelivery,
|
|
463
|
+
): Promise<OpenCodeDeliveryOutcome> {
|
|
464
|
+
let target: OCSession | null = null;
|
|
465
|
+
|
|
466
|
+
// Before the one allowed POST, retries are safe: no submission has happened.
|
|
467
|
+
// OpenCode must generate the message ID: its run loop treats IDs as
|
|
468
|
+
// lexicographically ordered, so arbitrary caller IDs can persist without
|
|
469
|
+
// ever becoming the active user turn. The unique inbox text correlates the
|
|
470
|
+
// generated message across confirmation and process-replay instead.
|
|
471
|
+
for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
|
|
472
|
+
if (state !== owner || !owner.connected) return 'failed';
|
|
473
|
+
if (attempt > 0) {
|
|
474
|
+
delivery.state = 'retried';
|
|
475
|
+
owner.totalEntriesRetried++;
|
|
476
|
+
await waitForDeliveryRetry(attempt);
|
|
477
|
+
if (state !== owner || !owner.connected) return 'failed';
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!target) {
|
|
481
|
+
try {
|
|
482
|
+
target = await resolveInjectionSession();
|
|
483
|
+
} catch (err) {
|
|
484
|
+
log(`entry ${delivery.entryId} target unavailable: ${err}`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (!target) return 'failed';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
if (await findInjectedMessage(target.id, delivery.text)) {
|
|
492
|
+
return 'delivered';
|
|
493
|
+
}
|
|
494
|
+
} catch (err) {
|
|
495
|
+
log(`entry ${delivery.entryId} confirmation unavailable: ${err}`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!delivery.allowSubmit) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// prompt_async is not idempotent. Submit at most once, then only poll for
|
|
504
|
+
// the exact text. A transport failure is ambiguous and follows the same
|
|
505
|
+
// confirmation-only path.
|
|
506
|
+
let status: number | null = null;
|
|
507
|
+
try {
|
|
508
|
+
status = await promptSession(target.id, {
|
|
509
|
+
parts: [{ type: 'text', text: delivery.text }],
|
|
510
|
+
});
|
|
511
|
+
} catch (err) {
|
|
512
|
+
log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
delivery.state = 'delivered-unconfirmed';
|
|
516
|
+
if (status !== null && status !== 200 && status !== 204) {
|
|
517
|
+
if (status === 404) clearBinding();
|
|
518
|
+
return 'failed';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
for (
|
|
522
|
+
let confirmationAttempt = 0;
|
|
523
|
+
confirmationAttempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length;
|
|
524
|
+
confirmationAttempt++
|
|
525
|
+
) {
|
|
526
|
+
if (confirmationAttempt > 0) {
|
|
527
|
+
delivery.state = 'retried';
|
|
528
|
+
owner.totalEntriesRetried++;
|
|
529
|
+
await waitForDeliveryRetry(confirmationAttempt);
|
|
530
|
+
if (state !== owner || !owner.connected) return 'delivered-unconfirmed';
|
|
531
|
+
delivery.state = 'delivered-unconfirmed';
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
if (await findInjectedMessage(target.id, delivery.text)) {
|
|
535
|
+
return 'delivered';
|
|
536
|
+
}
|
|
537
|
+
} catch (err) {
|
|
538
|
+
log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return 'delivered-unconfirmed';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// A replay begins from a durable inbox record but cannot know whether the
|
|
546
|
+
// prior process stopped before or after submission. Missing confirmation
|
|
547
|
+
// therefore remains unknown, not a definite delivery failure.
|
|
548
|
+
return delivery.allowSubmit ? 'failed' : 'delivered-unconfirmed';
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<void> {
|
|
552
|
+
if (owner.processingDeliveries) return;
|
|
553
|
+
owner.processingDeliveries = true;
|
|
554
|
+
try {
|
|
555
|
+
while (state === owner && owner.deliveryQueue.length > 0) {
|
|
556
|
+
const delivery = owner.deliveryQueue.shift()!;
|
|
557
|
+
let outcome: OpenCodeDeliveryOutcome = 'failed';
|
|
558
|
+
try {
|
|
559
|
+
outcome = await deliverOpenCodeEntry(owner, delivery);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
log(`entry ${delivery.entryId} delivery error: ${err}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
owner.activeDeliveries.delete(delivery.entryId);
|
|
565
|
+
if (outcome === 'delivered') {
|
|
566
|
+
owner.unconfirmedEntries.delete(delivery.entryId);
|
|
567
|
+
owner.failedEntries.delete(delivery.entryId);
|
|
568
|
+
rememberBounded(owner.deliveredEntries, delivery.entryId, delivery.text);
|
|
569
|
+
owner.totalEntriesInjected++;
|
|
570
|
+
delivery.resolve(true);
|
|
571
|
+
} else if (outcome === 'delivered-unconfirmed') {
|
|
572
|
+
owner.failedEntries.delete(delivery.entryId);
|
|
573
|
+
rememberBounded(owner.unconfirmedEntries, delivery.entryId, delivery.text);
|
|
574
|
+
delivery.resolve(false);
|
|
575
|
+
} else {
|
|
576
|
+
owner.unconfirmedEntries.delete(delivery.entryId);
|
|
577
|
+
rememberBounded(owner.failedEntries, delivery.entryId, delivery.text);
|
|
578
|
+
delivery.resolve(false);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} finally {
|
|
582
|
+
owner.processingDeliveries = false;
|
|
583
|
+
}
|
|
389
584
|
}
|
|
390
585
|
|
|
391
586
|
// ---------------------------------------------------------------------------
|
|
@@ -434,42 +629,67 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
|
|
|
434
629
|
}
|
|
435
630
|
|
|
436
631
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
632
|
+
* Queue one durable inbox entry for delivery into the bound OpenCode session.
|
|
633
|
+
* The durable inbox text identifies the OpenCode-generated user message, so
|
|
634
|
+
* retries and replay can confirm an earlier ambiguous submission without
|
|
635
|
+
* supplying an ordering-breaking caller message ID or running it twice.
|
|
439
636
|
*/
|
|
440
|
-
export
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
637
|
+
export function injectOpenCodeEntry(
|
|
638
|
+
text: string,
|
|
639
|
+
entryId: string = createHash('sha256').update(text).digest('hex'),
|
|
640
|
+
allowSubmit: boolean = true,
|
|
641
|
+
): Promise<boolean> {
|
|
642
|
+
const owner = state;
|
|
643
|
+
if (!owner?.connected) return Promise.resolve(false);
|
|
644
|
+
|
|
645
|
+
const deliveredText = owner.deliveredEntries.get(entryId);
|
|
646
|
+
if (deliveredText !== undefined) {
|
|
647
|
+
if (deliveredText !== text) {
|
|
648
|
+
log(`entry ${entryId} replay text mismatch`);
|
|
649
|
+
rememberBounded(owner.failedEntries, entryId, text);
|
|
650
|
+
return Promise.resolve(false);
|
|
651
|
+
}
|
|
652
|
+
return Promise.resolve(true);
|
|
653
|
+
}
|
|
446
654
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
return
|
|
655
|
+
const unconfirmedText = owner.unconfirmedEntries.get(entryId);
|
|
656
|
+
if (unconfirmedText !== undefined) {
|
|
657
|
+
if (unconfirmedText !== text) {
|
|
658
|
+
log(`entry ${entryId} unconfirmed replay text mismatch`);
|
|
659
|
+
rememberBounded(owner.failedEntries, entryId, text);
|
|
660
|
+
return Promise.resolve(false);
|
|
453
661
|
}
|
|
662
|
+
return Promise.resolve(false);
|
|
663
|
+
}
|
|
454
664
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (!replacement) return false;
|
|
462
|
-
if (await promptSession(replacement.id, body)) {
|
|
463
|
-
state.totalEntriesInjected++;
|
|
464
|
-
return true;
|
|
665
|
+
const active = owner.activeDeliveries.get(entryId);
|
|
666
|
+
if (active) {
|
|
667
|
+
if (active.text !== text) {
|
|
668
|
+
log(`entry ${entryId} active text mismatch`);
|
|
669
|
+
rememberBounded(owner.failedEntries, entryId, text);
|
|
670
|
+
return Promise.resolve(false);
|
|
465
671
|
}
|
|
466
|
-
|
|
467
|
-
return false;
|
|
468
|
-
} catch (err) {
|
|
469
|
-
log(`entry error: ${err}`);
|
|
470
|
-
clearBinding();
|
|
471
|
-
return false;
|
|
672
|
+
return active.promise;
|
|
472
673
|
}
|
|
674
|
+
|
|
675
|
+
let resolveDelivery!: (delivered: boolean) => void;
|
|
676
|
+
const promise = new Promise<boolean>((resolve) => {
|
|
677
|
+
resolveDelivery = resolve;
|
|
678
|
+
});
|
|
679
|
+
const delivery: OpenCodeDelivery = {
|
|
680
|
+
entryId,
|
|
681
|
+
text,
|
|
682
|
+
allowSubmit,
|
|
683
|
+
state: 'queued',
|
|
684
|
+
resolve: resolveDelivery,
|
|
685
|
+
promise,
|
|
686
|
+
};
|
|
687
|
+
owner.unconfirmedEntries.delete(entryId);
|
|
688
|
+
owner.failedEntries.delete(entryId);
|
|
689
|
+
owner.activeDeliveries.set(entryId, delivery);
|
|
690
|
+
owner.deliveryQueue.push(delivery);
|
|
691
|
+
void processOpenCodeDeliveries(owner);
|
|
692
|
+
return promise;
|
|
473
693
|
}
|
|
474
694
|
|
|
475
695
|
export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
|
|
@@ -483,24 +703,39 @@ export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
|
|
|
483
703
|
clearBinding();
|
|
484
704
|
return false;
|
|
485
705
|
} catch {
|
|
486
|
-
clearBinding();
|
|
487
706
|
return false;
|
|
488
707
|
}
|
|
489
708
|
}
|
|
490
709
|
|
|
491
710
|
export function disconnectOpenCodeDrone(): void {
|
|
711
|
+
abandonOpenCodeDeliveries(state);
|
|
492
712
|
state = null;
|
|
493
713
|
}
|
|
494
714
|
|
|
495
|
-
export
|
|
715
|
+
export interface OpenCodeConnectionState {
|
|
496
716
|
connected: boolean;
|
|
497
717
|
sessionId: string | null;
|
|
498
718
|
totalEntriesInjected: number;
|
|
499
|
-
|
|
719
|
+
totalEntriesRetried: number;
|
|
720
|
+
deliveryStates: Record<OpenCodeDeliveryState, number>;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export function getOpenCodeConnectionState(): OpenCodeConnectionState {
|
|
724
|
+
const deliveryStates: Record<OpenCodeDeliveryState, number> = {
|
|
725
|
+
queued: 0,
|
|
726
|
+
'delivered-unconfirmed': state?.unconfirmedEntries.size ?? 0,
|
|
727
|
+
retried: 0,
|
|
728
|
+
failed: state?.failedEntries.size ?? 0,
|
|
729
|
+
};
|
|
730
|
+
for (const delivery of state?.activeDeliveries.values() ?? []) {
|
|
731
|
+
deliveryStates[delivery.state]++;
|
|
732
|
+
}
|
|
500
733
|
return {
|
|
501
734
|
connected: state?.connected ?? false,
|
|
502
735
|
sessionId: state?.sessionId ?? null,
|
|
503
736
|
totalEntriesInjected: state?.totalEntriesInjected ?? 0,
|
|
737
|
+
totalEntriesRetried: state?.totalEntriesRetried ?? 0,
|
|
738
|
+
deliveryStates,
|
|
504
739
|
};
|
|
505
740
|
}
|
|
506
741
|
|
|
@@ -516,6 +751,7 @@ export function computeOpenCodePort(droneId: string, base: number = 14096): numb
|
|
|
516
751
|
|
|
517
752
|
/** Test-only cleanup for module state and the local cross-process binding. */
|
|
518
753
|
export function __resetOpenCodeDroneForTests(): void {
|
|
754
|
+
abandonOpenCodeDeliveries(state);
|
|
519
755
|
state = null;
|
|
520
756
|
for (const path of bindingPathsForTests) {
|
|
521
757
|
try {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const OPENCODE_WAKE_PATH_GUIDANCE =
|
|
2
|
+
'OpenCode wakes through HTTP entry injection into its session through the local HTTP API. ' +
|
|
3
|
+
'Borg writes each entry to the durable inbox before one injection attempt and ' +
|
|
4
|
+
'confirms delivery by the unique persisted entry text; no inbox Monitor or secondary wake loop is used. ' +
|
|
5
|
+
'If injection is rejected or cannot be ' +
|
|
6
|
+
'confirmed, the durable entry remains available and `borg_stream-status` reports ' +
|
|
7
|
+
'it as failed or delivered-unconfirmed; run `borg_read-log unread_only=true` and ' +
|
|
8
|
+
'drain the unread log to recover.';
|