borgmcp 3.11.0 → 3.12.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/THIRD_PARTY_NOTICES.md +5 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +8 -3
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +11 -0
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +11 -4
- package/dist/claude.js.map +1 -1
- package/dist/codex-app-wake.d.ts +3 -2
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +42 -9
- package/dist/codex-app-wake.js.map +1 -1
- package/dist/config-utils.d.ts +13 -12
- package/dist/config-utils.d.ts.map +1 -1
- package/dist/config-utils.js +150 -27
- package/dist/config-utils.js.map +1 -1
- package/dist/cube-activity-wake-copy.d.ts +1 -1
- package/dist/cube-activity-wake-copy.d.ts.map +1 -1
- package/dist/cube-activity-wake-copy.js +1 -1
- package/dist/cube-activity-wake-copy.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +3 -3
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +8 -13
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-drone.d.ts +11 -9
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +72 -48
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-launch-trust.d.ts +13 -0
- package/dist/opencode-launch-trust.d.ts.map +1 -0
- package/dist/opencode-launch-trust.js +40 -0
- package/dist/opencode-launch-trust.js.map +1 -0
- package/dist/opencode-plugin.d.ts +3 -0
- package/dist/opencode-plugin.d.ts.map +1 -1
- package/dist/opencode-plugin.js +26 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/remote-client.d.ts +2 -21
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +8 -6
- package/dist/remote-client.js.map +1 -1
- package/dist/server-handshake.d.ts +6 -1
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +1 -0
- package/dist/server-handshake.js.map +1 -1
- package/dist/setup-confirm.d.ts +1 -1
- package/dist/setup-confirm.js +3 -3
- package/dist/setup-confirm.js.map +1 -1
- package/docs/LOCAL_SERVER.md +4 -8
- package/docs/RELEASING.md +32 -308
- package/package.json +3 -2
- package/src/assimilate-cmd.ts +13 -3
- package/src/assimilate-deps.ts +11 -0
- package/src/claude.ts +16 -4
- package/src/codex-app-wake.ts +42 -8
- package/src/config-utils.ts +147 -23
- package/src/cube-activity-wake-copy.ts +1 -1
- package/src/index.ts +7 -0
- package/src/log-stream.ts +24 -15
- package/src/opencode-drone.ts +85 -55
- package/src/opencode-launch-trust.ts +46 -0
- package/src/opencode-plugin.ts +31 -1
- package/src/remote-client.ts +11 -25
- package/src/server-handshake.ts +4 -1
- package/src/setup-confirm.ts +3 -3
package/src/codex-app-wake.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from './cubes.js';
|
|
7
7
|
import { CodexAppServerClient } from './codex-app-server.js';
|
|
8
8
|
import { checkCodexBridgeHealthy } from './codex-remote.js';
|
|
9
|
-
import { hasPendingWakeActivity } from './remote-client.js';
|
|
9
|
+
import { hasPendingWakeActivity, hasPendingWakeEntry } from './remote-client.js';
|
|
10
10
|
import {
|
|
11
11
|
BORG_CODEX_REMOTE_WAKE_ENV,
|
|
12
12
|
resolveSessionAgentKind,
|
|
@@ -98,6 +98,7 @@ let wakeInFlight = false;
|
|
|
98
98
|
const pendingWakeRequests: Array<{
|
|
99
99
|
reason: string;
|
|
100
100
|
deliveryIdentity?: string;
|
|
101
|
+
sourceEntryId?: string;
|
|
101
102
|
deps: CodexWakeDeps;
|
|
102
103
|
}> = [];
|
|
103
104
|
const deliveredWakeKeys = new Set<string>();
|
|
@@ -108,6 +109,8 @@ const DELIVERED_WAKE_KEY_CAP = 100;
|
|
|
108
109
|
// (mid-turn thread) or missed (transient error) into ONE retried-until-delivered
|
|
109
110
|
// drain. The coalesce gate means a burst collapses to one poller, not N.
|
|
110
111
|
let retryDrainInFlight = false;
|
|
112
|
+
const retryDrainSourceEntryIds = new Set<string>();
|
|
113
|
+
let retryDrainHasUnscopedWork = false;
|
|
111
114
|
|
|
112
115
|
// gh#857 WI-2: timestamp of the last SUCCESSFUL wake delivery (per-entry OR
|
|
113
116
|
// retry-drain OR heartbeat). The heartbeat reads this (shouldFireHeartbeat) to
|
|
@@ -192,6 +195,7 @@ export interface CodexWakeDeps {
|
|
|
192
195
|
// Per-entry and retry-drain paths do not use this: their pending obligation is
|
|
193
196
|
// already established by a concrete delivered/deferred event.
|
|
194
197
|
hasPendingWork?: (active: ActiveCube) => Promise<boolean>;
|
|
198
|
+
hasPendingEntry?: (active: ActiveCube, entryId: string) => Promise<boolean>;
|
|
195
199
|
// gh#861 finding 2: lease-ownership gate for the heartbeat tick — a lease-LOSING
|
|
196
200
|
// duplicate child must NOT tick/inject (symmetry with the per-entry path, which
|
|
197
201
|
// only fires inside an SSE session that holds the stream lease). Heartbeat-only;
|
|
@@ -280,10 +284,11 @@ export function wakeCodexViaAppServer(
|
|
|
280
284
|
env: NodeJS.ProcessEnv = process.env,
|
|
281
285
|
deps: CodexWakeDeps = {},
|
|
282
286
|
deliveryIdentity?: string,
|
|
287
|
+
sourceEntryId?: string,
|
|
283
288
|
): void {
|
|
284
289
|
const target = resolveCodexWakeTarget(env);
|
|
285
290
|
if (!target.enabled) return;
|
|
286
|
-
pendingWakeRequests.push({ reason, deliveryIdentity, deps });
|
|
291
|
+
pendingWakeRequests.push({ reason, deliveryIdentity, sourceEntryId, deps });
|
|
287
292
|
if (wakeInFlight) return;
|
|
288
293
|
|
|
289
294
|
wakeInFlight = true;
|
|
@@ -295,21 +300,30 @@ export function wakeCodexViaAppServer(
|
|
|
295
300
|
async function drainCodexWakeQueue(): Promise<void> {
|
|
296
301
|
while (pendingWakeRequests.length > 0) {
|
|
297
302
|
const request = pendingWakeRequests.shift()!;
|
|
298
|
-
await wakeCodexTargeted(
|
|
303
|
+
await wakeCodexTargeted(
|
|
304
|
+
request.reason, request.deliveryIdentity, request.sourceEntryId, request.deps,
|
|
305
|
+
);
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
308
|
|
|
302
|
-
async function wakeCodexTargeted(
|
|
309
|
+
async function wakeCodexTargeted(
|
|
310
|
+
reason: string,
|
|
311
|
+
deliveryIdentity: string | undefined,
|
|
312
|
+
sourceEntryId: string | undefined,
|
|
313
|
+
deps: CodexWakeDeps,
|
|
314
|
+
): Promise<void> {
|
|
303
315
|
// gh#861 finding 1: another path (heartbeat/retry-drain) is mid-inject into the
|
|
304
316
|
// same thread — defer to the retry-drain so this entry isn't double-injected nor
|
|
305
317
|
// lost (the drain re-syncs the whole burst via the server read-cursor).
|
|
306
318
|
if (!tryAcquireInjectLock()) {
|
|
307
|
-
scheduleRetryDrain(deps);
|
|
319
|
+
scheduleRetryDrain(deps, sourceEntryId);
|
|
308
320
|
return;
|
|
309
321
|
}
|
|
310
322
|
try {
|
|
311
323
|
const active = await (deps.getActiveCube ?? getActiveCube)();
|
|
312
324
|
if (!active) return;
|
|
325
|
+
const pendingEntry = deps.hasPendingEntry ?? hasPendingWakeEntry;
|
|
326
|
+
if (sourceEntryId && !(await pendingEntry(active, sourceEntryId))) return;
|
|
313
327
|
// gh#855: resolve FRESH (live env socket + re-resolved thread), falling back
|
|
314
328
|
// to the launch-recorded file only when the env socket is absent.
|
|
315
329
|
const resolved = await resolveFreshCodexWakeTarget(active, deps);
|
|
@@ -326,9 +340,10 @@ async function wakeCodexTargeted(reason: string, deliveryIdentity: string | unde
|
|
|
326
340
|
// now. Schedule the retry-drain (coalesced, retried-until-delivered) so
|
|
327
341
|
// the burst's entries are drained once the thread goes idle; codex has no
|
|
328
342
|
// on-disk tail fallback like Claude's borg-inbox-monitor.
|
|
329
|
-
scheduleRetryDrain(deps);
|
|
343
|
+
scheduleRetryDrain(deps, sourceEntryId);
|
|
330
344
|
return;
|
|
331
345
|
}
|
|
346
|
+
if (sourceEntryId && !(await pendingEntry(active, sourceEntryId))) return;
|
|
332
347
|
await client.startTurn(threadId, reason);
|
|
333
348
|
rememberDeliveredWake(wakeKey);
|
|
334
349
|
markDelivered(deps);
|
|
@@ -340,7 +355,7 @@ async function wakeCodexTargeted(reason: string, deliveryIdentity: string | unde
|
|
|
340
355
|
// swallowed (the old best-effort drop let a single blip lose an entry).
|
|
341
356
|
// Schedule the retry-drain so the wake is retried-until-delivered; the SSE
|
|
342
357
|
// stream is never broken (this is fire-and-forget).
|
|
343
|
-
scheduleRetryDrain(deps);
|
|
358
|
+
scheduleRetryDrain(deps, sourceEntryId);
|
|
344
359
|
} finally {
|
|
345
360
|
releaseInjectLock();
|
|
346
361
|
}
|
|
@@ -357,7 +372,9 @@ async function wakeCodexTargeted(reason: string, deliveryIdentity: string | unde
|
|
|
357
372
|
* (wakeRetryExpired); the gh#857 WI-2 heartbeat is the backstop beyond that.
|
|
358
373
|
* Never throws into the SSE path (fire-and-forget).
|
|
359
374
|
*/
|
|
360
|
-
function scheduleRetryDrain(deps: CodexWakeDeps): void {
|
|
375
|
+
function scheduleRetryDrain(deps: CodexWakeDeps, sourceEntryId?: string): void {
|
|
376
|
+
if (sourceEntryId) retryDrainSourceEntryIds.add(sourceEntryId);
|
|
377
|
+
else retryDrainHasUnscopedWork = true;
|
|
361
378
|
if (retryDrainInFlight) return; // coalesce: one loop covers all deferred/missed wakes
|
|
362
379
|
retryDrainInFlight = true;
|
|
363
380
|
void runRetryDrainLoop(deps).finally(() => {
|
|
@@ -383,6 +400,15 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
|
|
|
383
400
|
try {
|
|
384
401
|
const active = await (deps.getActiveCube ?? getActiveCube)();
|
|
385
402
|
if (!active) continue; // no active cube yet → keep retrying (until age cap)
|
|
403
|
+
const pendingEntry = deps.hasPendingEntry ?? hasPendingWakeEntry;
|
|
404
|
+
for (const entryId of retryDrainSourceEntryIds) {
|
|
405
|
+
try {
|
|
406
|
+
if (!(await pendingEntry(active, entryId))) retryDrainSourceEntryIds.delete(entryId);
|
|
407
|
+
} catch {
|
|
408
|
+
// Retain the obligation until unread state can be checked.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!retryDrainHasUnscopedWork && retryDrainSourceEntryIds.size === 0) return;
|
|
386
412
|
// gh#855: same FRESH resolution as the per-entry wake, so a stale launch
|
|
387
413
|
// probe can't defeat the retry-drain either.
|
|
388
414
|
const resolved = await resolveFreshCodexWakeTarget(active, deps);
|
|
@@ -395,7 +421,13 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
|
|
|
395
421
|
if (thread?.status?.type === 'active') {
|
|
396
422
|
continue; // re-defer: still mid-turn (backoff before next poll)
|
|
397
423
|
}
|
|
424
|
+
for (const entryId of retryDrainSourceEntryIds) {
|
|
425
|
+
if (!(await pendingEntry(active, entryId))) retryDrainSourceEntryIds.delete(entryId);
|
|
426
|
+
}
|
|
427
|
+
if (!retryDrainHasUnscopedWork && retryDrainSourceEntryIds.size === 0) return;
|
|
398
428
|
await client.startTurn(threadId, CODEX_CATCHUP_PROMPT);
|
|
429
|
+
retryDrainSourceEntryIds.clear();
|
|
430
|
+
retryDrainHasUnscopedWork = false;
|
|
399
431
|
markDelivered(deps);
|
|
400
432
|
return; // drain delivered → server read-cursor drains all unread → done
|
|
401
433
|
} finally {
|
|
@@ -515,6 +547,8 @@ export function resetCodexWakeForTests(): void {
|
|
|
515
547
|
deliveredWakeKeys.clear();
|
|
516
548
|
deliveredWakeKeyOrder.length = 0;
|
|
517
549
|
retryDrainInFlight = false;
|
|
550
|
+
retryDrainSourceEntryIds.clear();
|
|
551
|
+
retryDrainHasUnscopedWork = false;
|
|
518
552
|
lastDeliveredAt = null;
|
|
519
553
|
heartbeatInFlight = false;
|
|
520
554
|
injectInFlight = false;
|
package/src/config-utils.ts
CHANGED
|
@@ -24,6 +24,10 @@ import { shellEscape } from './shell-escape.js';
|
|
|
24
24
|
import { BORG_STATE_ROOT_ENV, borgAgentConfigEnv, borgHomeRoot } from './private-root.js';
|
|
25
25
|
import type { LaunchAccessPaths } from './launch-access.js';
|
|
26
26
|
import { BORG_LAUNCH_EXPECTED_SEAT_ENV } from './cubes.js';
|
|
27
|
+
import {
|
|
28
|
+
OPENCODE_SERVER_PASSWORD_ENV,
|
|
29
|
+
OPENCODE_SERVER_PASSWORD_REFERENCE,
|
|
30
|
+
} from './opencode-launch-trust.js';
|
|
27
31
|
|
|
28
32
|
// Get __dirname equivalent in ESM
|
|
29
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -53,6 +57,7 @@ const CLAUDE_CONFIG_PATH = path.join(CONFIG_HOME, '.claude.json');
|
|
|
53
57
|
const CODEX_CONFIG_PATH = path.join(CONFIG_HOME, '.codex', 'config.toml');
|
|
54
58
|
const CODEX_HOOKS_PATH = path.join(CONFIG_HOME, '.codex', 'hooks.json');
|
|
55
59
|
const OPENCODE_CONFIG_PATH = path.join(CONFIG_HOME, '.config', 'opencode', 'opencode.json');
|
|
60
|
+
const OPENCODE_GLOBAL_CONFIG_FILENAMES = ['config.json', 'opencode.json', 'opencode.jsonc'] as const;
|
|
56
61
|
const MCP_SERVER_NAME = 'borg';
|
|
57
62
|
|
|
58
63
|
function settingsPath(): string {
|
|
@@ -80,6 +85,125 @@ function readJsonFile(p: string): any {
|
|
|
80
85
|
return JSON.parse(text);
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
function parseJsonc(text: string): unknown {
|
|
89
|
+
let stripped = '';
|
|
90
|
+
let inString = false;
|
|
91
|
+
let escaped = false;
|
|
92
|
+
let lineComment = false;
|
|
93
|
+
let blockComment = false;
|
|
94
|
+
|
|
95
|
+
for (let index = 0; index < text.length; index++) {
|
|
96
|
+
const char = text[index];
|
|
97
|
+
const next = text[index + 1];
|
|
98
|
+
if (lineComment) {
|
|
99
|
+
if (char === '\n' || char === '\r') {
|
|
100
|
+
lineComment = false;
|
|
101
|
+
stripped += char;
|
|
102
|
+
} else {
|
|
103
|
+
stripped += ' ';
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (blockComment) {
|
|
108
|
+
if (char === '*' && next === '/') {
|
|
109
|
+
stripped += ' ';
|
|
110
|
+
index++;
|
|
111
|
+
blockComment = false;
|
|
112
|
+
} else {
|
|
113
|
+
stripped += char === '\n' || char === '\r' ? char : ' ';
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (inString) {
|
|
118
|
+
stripped += char;
|
|
119
|
+
if (escaped) escaped = false;
|
|
120
|
+
else if (char === '\\') escaped = true;
|
|
121
|
+
else if (char === '"') inString = false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (char === '"') {
|
|
125
|
+
inString = true;
|
|
126
|
+
stripped += char;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (char === '/' && next === '/') {
|
|
130
|
+
stripped += ' ';
|
|
131
|
+
index++;
|
|
132
|
+
lineComment = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (char === '/' && next === '*') {
|
|
136
|
+
stripped += ' ';
|
|
137
|
+
index++;
|
|
138
|
+
blockComment = true;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
stripped += char;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (inString || blockComment) throw new SyntaxError('unterminated JSONC token');
|
|
145
|
+
|
|
146
|
+
let withoutTrailingCommas = '';
|
|
147
|
+
inString = false;
|
|
148
|
+
escaped = false;
|
|
149
|
+
for (let index = 0; index < stripped.length; index++) {
|
|
150
|
+
const char = stripped[index];
|
|
151
|
+
if (inString) {
|
|
152
|
+
withoutTrailingCommas += char;
|
|
153
|
+
if (escaped) escaped = false;
|
|
154
|
+
else if (char === '\\') escaped = true;
|
|
155
|
+
else if (char === '"') inString = false;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (char === '"') {
|
|
159
|
+
inString = true;
|
|
160
|
+
withoutTrailingCommas += char;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (char === ',') {
|
|
164
|
+
let lookahead = index + 1;
|
|
165
|
+
while (/\s/.test(stripped[lookahead] ?? '')) lookahead++;
|
|
166
|
+
if (stripped[lookahead] === '}' || stripped[lookahead] === ']') continue;
|
|
167
|
+
}
|
|
168
|
+
withoutTrailingCommas += char;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return JSON.parse(withoutTrailingCommas.replace(/^\uFEFF/, ''));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
175
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mergeOpenCodeConfigValue(target: unknown, source: unknown): unknown {
|
|
179
|
+
if (!isPlainRecord(target) || !isPlainRecord(source)) return source;
|
|
180
|
+
const merged = Object.assign(Object.create(null) as Record<string, unknown>, target);
|
|
181
|
+
for (const [key, value] of Object.entries(source)) {
|
|
182
|
+
merged[key] = key in merged ? mergeOpenCodeConfigValue(merged[key], value) : value;
|
|
183
|
+
}
|
|
184
|
+
return merged;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function readOpenCodeGlobalConfig(configPath: string): Record<string, unknown> {
|
|
188
|
+
const basename = path.basename(configPath);
|
|
189
|
+
const paths = OPENCODE_GLOBAL_CONFIG_FILENAMES.includes(
|
|
190
|
+
basename as typeof OPENCODE_GLOBAL_CONFIG_FILENAMES[number],
|
|
191
|
+
)
|
|
192
|
+
? OPENCODE_GLOBAL_CONFIG_FILENAMES.map((filename) => path.join(path.dirname(configPath), filename))
|
|
193
|
+
: [configPath];
|
|
194
|
+
let merged: unknown = {};
|
|
195
|
+
for (const candidate of paths) {
|
|
196
|
+
if (!fs.existsSync(candidate)) continue;
|
|
197
|
+
const text = fs.readFileSync(candidate, 'utf-8');
|
|
198
|
+
if (text.length === 0) continue;
|
|
199
|
+
const parsed = parseJsonc(text);
|
|
200
|
+
if (!isPlainRecord(parsed)) throw new TypeError(`OpenCode config is not an object: ${candidate}`);
|
|
201
|
+
merged = mergeOpenCodeConfigValue(merged, parsed);
|
|
202
|
+
}
|
|
203
|
+
if (!isPlainRecord(merged)) throw new TypeError('OpenCode global config is not an object');
|
|
204
|
+
return merged;
|
|
205
|
+
}
|
|
206
|
+
|
|
83
207
|
function writeJsonFile(p: string, data: any): void {
|
|
84
208
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
85
209
|
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
@@ -1162,22 +1286,18 @@ export function isCodexUserPromptSubmitHookRegistered(hooksPath: string = CODEX_
|
|
|
1162
1286
|
// ─── OpenCode MCP integration ────────────────────────────────────────────
|
|
1163
1287
|
|
|
1164
1288
|
/**
|
|
1165
|
-
* Detect whether the borg MCP server is
|
|
1166
|
-
* config
|
|
1289
|
+
* Detect whether the borg MCP server is registered in OpenCode's effective
|
|
1290
|
+
* global config. OpenCode 1.18.15 loads `config.json`, `opencode.json`, then
|
|
1291
|
+
* `opencode.jsonc`, merging later files over earlier ones.
|
|
1167
1292
|
*
|
|
1168
|
-
* Reads
|
|
1293
|
+
* Reads JSON or JSONC and checks the effective `mcp.borg` entry for
|
|
1169
1294
|
* `type: "local"`. Safe-default: any read error returns `false`.
|
|
1170
1295
|
*/
|
|
1171
1296
|
export function isOpenCodeMcpServerConfigured(
|
|
1172
1297
|
configPath: string = path.join(borgHomeRoot(), '.config', 'opencode', 'opencode.json')
|
|
1173
1298
|
): boolean {
|
|
1174
1299
|
try {
|
|
1175
|
-
|
|
1176
|
-
const text = fs.readFileSync(configPath, 'utf-8');
|
|
1177
|
-
if (!text.trim()) return false;
|
|
1178
|
-
const parsed = JSON.parse(text);
|
|
1179
|
-
if (!parsed || typeof parsed !== 'object') return false;
|
|
1180
|
-
const borgServer = (parsed as any).mcp?.borg;
|
|
1300
|
+
const borgServer = (readOpenCodeGlobalConfig(configPath) as any).mcp?.borg;
|
|
1181
1301
|
if (!borgServer || typeof borgServer !== 'object') return false;
|
|
1182
1302
|
return borgServer.type === 'local';
|
|
1183
1303
|
} catch {
|
|
@@ -1186,22 +1306,24 @@ export function isOpenCodeMcpServerConfigured(
|
|
|
1186
1306
|
}
|
|
1187
1307
|
|
|
1188
1308
|
/**
|
|
1189
|
-
* Launch-time OpenCode registration check.
|
|
1190
|
-
*
|
|
1191
|
-
* identified by its non-empty expected-seat
|
|
1192
|
-
*
|
|
1309
|
+
* Launch-time OpenCode registration check. Every launch requires the exact
|
|
1310
|
+
* password substitution that carries its ephemeral credential into OpenCode's
|
|
1311
|
+
* MCP child. A targeted `borg launch`, identified by its non-empty expected-seat
|
|
1312
|
+
* marker, additionally requires the substitution that carries that marker.
|
|
1193
1313
|
*/
|
|
1194
1314
|
export function isOpenCodeMcpServerConfiguredForLaunch(
|
|
1195
1315
|
configPath: string = path.join(borgHomeRoot(), '.config', 'opencode', 'opencode.json'),
|
|
1196
1316
|
env: NodeJS.ProcessEnv = process.env,
|
|
1197
1317
|
): boolean {
|
|
1198
1318
|
if (!isOpenCodeMcpServerConfigured(configPath)) return false;
|
|
1199
|
-
if (!env[BORG_LAUNCH_EXPECTED_SEAT_ENV]) return true;
|
|
1200
1319
|
try {
|
|
1201
|
-
const borgServer =
|
|
1320
|
+
const borgServer = (readOpenCodeGlobalConfig(configPath) as any).mcp?.borg;
|
|
1202
1321
|
const environment = borgServer?.environment ?? borgServer?.env;
|
|
1203
|
-
|
|
1204
|
-
|
|
1322
|
+
if (environment?.[OPENCODE_SERVER_PASSWORD_ENV] !== OPENCODE_SERVER_PASSWORD_REFERENCE) {
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
if (!env[BORG_LAUNCH_EXPECTED_SEAT_ENV]) return true;
|
|
1326
|
+
return environment?.[BORG_LAUNCH_EXPECTED_SEAT_ENV] === OPENCODE_LAUNCH_EXPECTED_SEAT_REFERENCE;
|
|
1205
1327
|
} catch {
|
|
1206
1328
|
return false;
|
|
1207
1329
|
}
|
|
@@ -1278,11 +1400,11 @@ export function addOpenCodeLaunchAccess(
|
|
|
1278
1400
|
|
|
1279
1401
|
/**
|
|
1280
1402
|
* Add borg MCP server to OpenCode using `opencode mcp add` CLI.
|
|
1281
|
-
* Pins activation and agent-kind signals plus
|
|
1282
|
-
* for the launch-scoped expected seat. OpenCode resolves
|
|
1283
|
-
* own launch environment before starting the MCP child,
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
1403
|
+
* Pins activation and agent-kind signals plus OpenCode config substitutions
|
|
1404
|
+
* for the launch-scoped password and expected seat. OpenCode resolves
|
|
1405
|
+
* `{env:NAME}` from its own launch environment before starting the MCP child,
|
|
1406
|
+
* so no launch credential is persisted. Existing configs with BORG_OPENCODE
|
|
1407
|
+
* remain supported by the runtime fallback after launch-time self-healing.
|
|
1286
1408
|
*/
|
|
1287
1409
|
export function addOpenCodeMcpServer(): void {
|
|
1288
1410
|
try {
|
|
@@ -1295,8 +1417,10 @@ export function addOpenCodeMcpServer(): void {
|
|
|
1295
1417
|
: '';
|
|
1296
1418
|
const launchExpectedSeatEnvArg =
|
|
1297
1419
|
` --env ${BORG_LAUNCH_EXPECTED_SEAT_ENV}=${shellQuote(OPENCODE_LAUNCH_EXPECTED_SEAT_REFERENCE)}`;
|
|
1420
|
+
const apiPasswordEnvArg =
|
|
1421
|
+
` --env ${OPENCODE_SERVER_PASSWORD_ENV}=${shellQuote(OPENCODE_SERVER_PASSWORD_REFERENCE)}`;
|
|
1298
1422
|
execSync(
|
|
1299
|
-
`opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg}${launchExpectedSeatEnvArg} -- ${shellQuote(MCP_COMMAND)}`,
|
|
1423
|
+
`opencode mcp add borg --env BORG_SESSION=1 --env BORG_AGENT_KIND=opencode --env BORG_OPENCODE=1${apiUrlEnvArg}${stateRootEnvArg}${launchExpectedSeatEnvArg}${apiPasswordEnvArg} -- ${shellQuote(MCP_COMMAND)}`,
|
|
1300
1424
|
{ stdio: 'inherit', env: borgAgentConfigEnv(process.env) }
|
|
1301
1425
|
);
|
|
1302
1426
|
} catch (error: any) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
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.';
|
|
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. If the unread drain is empty, resume silently without a liveness post or full regen.';
|
|
3
3
|
|
|
4
4
|
export function formatCubeActivityWakeMessage(detail: string): string {
|
|
5
5
|
return `${CUBE_ACTIVITY_RESUME_WAKE_MESSAGE}\n${detail}`;
|
package/src/index.ts
CHANGED
|
@@ -139,6 +139,7 @@ import {
|
|
|
139
139
|
openCodeLaunchBinding,
|
|
140
140
|
} from './opencode-drone.js';
|
|
141
141
|
import { installBorgPlugin } from './opencode-plugin.js';
|
|
142
|
+
import { openCodeApiPasswordFromEnv } from './opencode-launch-trust.js';
|
|
142
143
|
import { setModuleInjectOpenCode } from './log-stream.js';
|
|
143
144
|
import {
|
|
144
145
|
lifecycleSignalForMessage,
|
|
@@ -261,9 +262,15 @@ export async function connectOpenCodeRuntime(
|
|
|
261
262
|
console.error(OPEN_CODE_PORT_MISSING_DIAGNOSTIC);
|
|
262
263
|
return false;
|
|
263
264
|
}
|
|
265
|
+
const apiPassword = openCodeApiPasswordFromEnv(env);
|
|
266
|
+
if (apiPassword === null) {
|
|
267
|
+
console.error('OpenCode API credential is missing or unverifiable; skipping OpenCode entry injection. Relaunch through borg.');
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
264
270
|
const binding = openCodeLaunchBinding(configuredPort);
|
|
265
271
|
await (deps.connect ?? connectOpenCodeDrone)({
|
|
266
272
|
serverUrl: binding.serverUrl,
|
|
273
|
+
apiPassword,
|
|
267
274
|
directory: active.worktree ?? findProjectRoot(),
|
|
268
275
|
droneLabel: active.droneLabel,
|
|
269
276
|
cubeName: active.name,
|
package/src/log-stream.ts
CHANGED
|
@@ -125,12 +125,14 @@ function isProcessAlive(pid: number): boolean {
|
|
|
125
125
|
* Used by defaultDeps when no explicit injectOpenCode is supplied.
|
|
126
126
|
*/
|
|
127
127
|
let _moduleInjectOpenCode:
|
|
128
|
-
| ((text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string
|
|
128
|
+
| ((text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string,
|
|
129
|
+
isSourcePending?: () => Promise<boolean>) => Promise<boolean>)
|
|
129
130
|
| undefined;
|
|
130
131
|
let _moduleSettleOpenCodeEntry: ((sourceEntryId: string) => void) | undefined;
|
|
131
132
|
|
|
132
133
|
export function setModuleInjectOpenCode(
|
|
133
|
-
fn: (text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string
|
|
134
|
+
fn: (text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string,
|
|
135
|
+
isSourcePending?: () => Promise<boolean>) => Promise<boolean>,
|
|
134
136
|
settle: (sourceEntryId: string) => void,
|
|
135
137
|
): void {
|
|
136
138
|
_moduleInjectOpenCode = fn;
|
|
@@ -392,7 +394,7 @@ export interface StreamDeps {
|
|
|
392
394
|
renderedLine: string
|
|
393
395
|
) => Promise<boolean>;
|
|
394
396
|
/** Optional Codex app-server wake sink; tests inject a spy. */
|
|
395
|
-
wakeCodex?: (reason: string, deliveryIdentity?: string) => void;
|
|
397
|
+
wakeCodex?: (reason: string, deliveryIdentity?: string, sourceEntryId?: string) => void;
|
|
396
398
|
/** Override the heartbeat watchdog timeout. */
|
|
397
399
|
heartbeatTimeoutMs?: number;
|
|
398
400
|
/** Override HWM divergence grace for focused tests. */
|
|
@@ -409,6 +411,7 @@ export interface StreamDeps {
|
|
|
409
411
|
entryId: string,
|
|
410
412
|
allowSubmit: boolean,
|
|
411
413
|
sourceEntryId?: string,
|
|
414
|
+
isSourcePending?: () => Promise<boolean>,
|
|
412
415
|
) => Promise<boolean>;
|
|
413
416
|
/** Inspect whether one retry source remains beyond the durable unread cursor. */
|
|
414
417
|
hasPendingWakeEntry?: (active: ActiveCube, entryId: string) => Promise<boolean>;
|
|
@@ -422,16 +425,16 @@ const defaultDeps: Required<StreamDeps> = {
|
|
|
422
425
|
getCursor: getLocalServerCursor,
|
|
423
426
|
appendLine: defaultAppendLine,
|
|
424
427
|
hasInboxEntryId: defaultHasInboxEntryId,
|
|
425
|
-
wakeCodex: (reason, deliveryIdentity) =>
|
|
426
|
-
wakeCodexViaAppServer(reason, process.env, {}, deliveryIdentity),
|
|
428
|
+
wakeCodex: (reason, deliveryIdentity, sourceEntryId) =>
|
|
429
|
+
wakeCodexViaAppServer(reason, process.env, {}, deliveryIdentity, sourceEntryId),
|
|
427
430
|
heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,
|
|
428
431
|
hwmDivergenceGraceMs: HWM_DIVERGENCE_GRACE_MS,
|
|
429
432
|
abortSignal: new AbortController().signal,
|
|
430
433
|
ownerDeps: {},
|
|
431
434
|
ownerStaleMs: 70_000,
|
|
432
|
-
injectOpenCode: (text, entryId, allowSubmit, sourceEntryId) =>
|
|
435
|
+
injectOpenCode: (text, entryId, allowSubmit, sourceEntryId, isSourcePending) =>
|
|
433
436
|
_moduleInjectOpenCode
|
|
434
|
-
? _moduleInjectOpenCode(text, entryId, allowSubmit, sourceEntryId)
|
|
437
|
+
? _moduleInjectOpenCode(text, entryId, allowSubmit, sourceEntryId, isSourcePending)
|
|
435
438
|
: Promise.resolve(false),
|
|
436
439
|
hasPendingWakeEntry: (active, entryId) => hasPendingDurableWakeEntry(
|
|
437
440
|
active as Parameters<typeof hasPendingDurableWakeEntry>[0],
|
|
@@ -902,7 +905,10 @@ export async function streamOnce(
|
|
|
902
905
|
// OpenCode queue. A still-unread re-ping may reconcile the prior attempt;
|
|
903
906
|
// its source entry ID prevents a new nonce from resubmitting that prompt.
|
|
904
907
|
if (isReping) {
|
|
905
|
-
await injectOpenCode(
|
|
908
|
+
await injectOpenCode(
|
|
909
|
+
formatOpenCodeWakePrompt(line), deliveryId, true, ev.id,
|
|
910
|
+
() => hasPendingWakeEntry(active, ev.id),
|
|
911
|
+
);
|
|
906
912
|
}
|
|
907
913
|
markEventPersisted(ev.id, ev.data?.created_at ?? '');
|
|
908
914
|
return 'persisted-skip';
|
|
@@ -910,12 +916,12 @@ export async function streamOnce(
|
|
|
910
916
|
// The inbox append is the durable record. OpenCode injection is only the
|
|
911
917
|
// wake attempt and may return before the agent finishes processing.
|
|
912
918
|
await appendLine(active.cubeId, active.droneId, line);
|
|
913
|
-
const openCodeDelivered =
|
|
914
|
-
|
|
915
|
-
|
|
919
|
+
const openCodeDelivered = await injectOpenCode(
|
|
920
|
+
formatOpenCodeWakePrompt(line), deliveryId, true, ev.id,
|
|
921
|
+
() => hasPendingWakeEntry(active, ev.id),
|
|
922
|
+
);
|
|
916
923
|
if (!openCodeDelivered) {
|
|
917
|
-
|
|
918
|
-
else wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce);
|
|
924
|
+
wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce, ev.id);
|
|
919
925
|
}
|
|
920
926
|
return 'written';
|
|
921
927
|
};
|
|
@@ -1234,9 +1240,12 @@ export async function streamOnce(
|
|
|
1234
1240
|
if (
|
|
1235
1241
|
wakeNonce !== undefined &&
|
|
1236
1242
|
await shouldDeliverWakeRetry(event.id) &&
|
|
1237
|
-
!(await injectOpenCode(
|
|
1243
|
+
!(await injectOpenCode(
|
|
1244
|
+
formatOpenCodeWakePrompt(line), wakeNonce, true, event.id,
|
|
1245
|
+
() => hasPendingWakeEntry(active, event.id),
|
|
1246
|
+
))
|
|
1238
1247
|
) {
|
|
1239
|
-
wakeCodex(formatCodexWakePrompt(line), wakeNonce);
|
|
1248
|
+
wakeCodex(formatCodexWakePrompt(line), wakeNonce, event.id);
|
|
1240
1249
|
}
|
|
1241
1250
|
continue;
|
|
1242
1251
|
}
|