borgmcp 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-integration-health.d.ts +2 -0
- package/dist/agent-integration-health.d.ts.map +1 -1
- package/dist/agent-integration-health.js +18 -1
- package/dist/agent-integration-health.js.map +1 -1
- package/dist/assimilate-cmd.d.ts +3 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +12 -0
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +4 -2
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/backends/launch-all-terminals.d.ts.map +1 -1
- package/dist/backends/launch-all-terminals.js +6 -3
- package/dist/backends/launch-all-terminals.js.map +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/index.d.ts.map +1 -1
- package/dist/index.js +27 -4
- package/dist/index.js.map +1 -1
- package/dist/opencode-drone.d.ts +19 -0
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +374 -67
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-seat-identity.d.ts +1 -1
- package/dist/opencode-seat-identity.d.ts.map +1 -1
- package/dist/opencode-seat-identity.js.map +1 -1
- package/dist/private-root.d.ts +2 -0
- package/dist/private-root.d.ts.map +1 -1
- package/dist/private-root.js +35 -1
- package/dist/private-root.js.map +1 -1
- package/dist/roster-render.d.ts.map +1 -1
- package/dist/roster-render.js +2 -3
- package/dist/roster-render.js.map +1 -1
- package/dist/seats.d.ts +5 -0
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +10 -0
- package/dist/seats.js.map +1 -1
- package/dist/server-errors.d.ts +22 -0
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +32 -0
- package/dist/server-errors.js.map +1 -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/stream-status.d.ts.map +1 -1
- package/dist/stream-status.js +7 -2
- package/dist/stream-status.js.map +1 -1
- package/dist/terminal-title.d.ts.map +1 -1
- package/dist/terminal-title.js +3 -2
- package/dist/terminal-title.js.map +1 -1
- package/dist/update-cmd.d.ts +2 -1
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +110 -41
- package/dist/update-cmd.js.map +1 -1
- package/docs/LOCAL_SERVER.md +21 -10
- package/package.json +1 -1
- package/src/agent-integration-health.ts +20 -1
- package/src/assimilate-cmd.ts +17 -0
- package/src/assimilate-deps.ts +4 -1
- package/src/backends/launch-all-terminals.ts +6 -3
- package/src/cli-help.ts +7 -4
- package/src/index.ts +30 -3
- package/src/opencode-drone.ts +461 -73
- package/src/opencode-seat-identity.ts +1 -0
- package/src/private-root.ts +38 -1
- package/src/roster-render.ts +2 -3
- package/src/seats.ts +15 -0
- package/src/server-errors.ts +50 -0
- package/src/server-handshake.ts +1 -0
- package/src/stream-status.ts +7 -2
- package/src/terminal-title.ts +4 -2
- package/src/update-cmd.ts +118 -41
package/src/opencode-drone.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants,
|
|
4
|
+
existsSync,
|
|
5
|
+
fchmodSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
writeSync,
|
|
14
|
+
} from 'fs';
|
|
15
|
+
import { createHash, randomUUID } from 'crypto';
|
|
3
16
|
import { createServer } from 'node:net';
|
|
4
17
|
import { join } from 'path';
|
|
5
18
|
import { tmpdir } from 'os';
|
|
19
|
+
import {
|
|
20
|
+
borgConfigRoot,
|
|
21
|
+
ensurePrivateBorgConfigRoot,
|
|
22
|
+
ensurePrivateBorgConfigRootSync,
|
|
23
|
+
} from './private-root.js';
|
|
6
24
|
import {
|
|
7
25
|
OPENCODE_INJECTED_ENTRY_METADATA_KEY,
|
|
8
26
|
OPENCODE_WAKE_IDENTITY_METADATA_KEY,
|
|
@@ -14,11 +32,129 @@ import {
|
|
|
14
32
|
OPENCODE_SERVER_USERNAME,
|
|
15
33
|
type OpenCodeLaunchTrust,
|
|
16
34
|
} from './opencode-launch-trust.js';
|
|
35
|
+
import {
|
|
36
|
+
OpenCodeAuthenticationError,
|
|
37
|
+
OpenCodeHttpError,
|
|
38
|
+
OpenCodeResponseError,
|
|
39
|
+
OpenCodeUnreachableError,
|
|
40
|
+
type OpenCodeFailureCode,
|
|
41
|
+
} from './server-errors.js';
|
|
42
|
+
|
|
43
|
+
const OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES = 64 * 1024;
|
|
44
|
+
const diagnosticLogPathsForTests = new Set<string>();
|
|
45
|
+
|
|
46
|
+
function stateIdentityDigest(current: OpenCodeDroneState): string {
|
|
47
|
+
const key = [current.serverUrl, current.directory, current.cubeName, current.droneLabel].join('\0');
|
|
48
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 24);
|
|
49
|
+
}
|
|
17
50
|
|
|
18
|
-
|
|
19
|
-
|
|
51
|
+
export function openCodeStartupDiagnosticLogPath(): string {
|
|
52
|
+
return join(borgConfigRoot(), 'opencode-drone-startup.log');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function diagnosticLogPath(owner: OpenCodeDroneState | null): string {
|
|
56
|
+
const root = borgConfigRoot();
|
|
57
|
+
const path = owner
|
|
58
|
+
? join(root, `opencode-drone-${stateIdentityDigest(owner)}.log`)
|
|
59
|
+
: openCodeStartupDiagnosticLogPath();
|
|
60
|
+
diagnosticLogPathsForTests.add(path);
|
|
61
|
+
return path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function log(msg: string, owner: OpenCodeDroneState | null = state, throwOnFailure = false) {
|
|
20
65
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
21
|
-
|
|
66
|
+
let descriptor: number | null = null;
|
|
67
|
+
let temporaryDescriptor: number | null = null;
|
|
68
|
+
let temporary: string | null = null;
|
|
69
|
+
try {
|
|
70
|
+
ensurePrivateBorgConfigRootSync(borgConfigRoot());
|
|
71
|
+
const path = diagnosticLogPath(owner);
|
|
72
|
+
// The private root is the primary boundary. Where available, no-follow also
|
|
73
|
+
// closes the final replacement gap between root verification and this open.
|
|
74
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
75
|
+
descriptor = openSync(
|
|
76
|
+
path,
|
|
77
|
+
constants.O_RDWR |
|
|
78
|
+
constants.O_APPEND |
|
|
79
|
+
constants.O_CREAT |
|
|
80
|
+
noFollow,
|
|
81
|
+
0o600,
|
|
82
|
+
);
|
|
83
|
+
if (!fstatSync(descriptor).isFile()) {
|
|
84
|
+
throw Object.assign(new Error('OpenCode diagnostic log is not a regular file'), { code: 'EINVAL' });
|
|
85
|
+
}
|
|
86
|
+
fchmodSync(descriptor, 0o600);
|
|
87
|
+
writeSync(descriptor, line, null, 'utf8');
|
|
88
|
+
|
|
89
|
+
const size = fstatSync(descriptor).size;
|
|
90
|
+
if (size <= OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES) return;
|
|
91
|
+
const tail = Buffer.allocUnsafe(OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES);
|
|
92
|
+
let bytesRead = 0;
|
|
93
|
+
while (bytesRead < tail.length) {
|
|
94
|
+
const count = readSync(
|
|
95
|
+
descriptor,
|
|
96
|
+
tail,
|
|
97
|
+
bytesRead,
|
|
98
|
+
tail.length - bytesRead,
|
|
99
|
+
size - tail.length + bytesRead,
|
|
100
|
+
);
|
|
101
|
+
if (count === 0) break;
|
|
102
|
+
bytesRead += count;
|
|
103
|
+
}
|
|
104
|
+
const completeTail = tail.subarray(0, bytesRead);
|
|
105
|
+
const firstNewline = completeTail.indexOf(0x0a);
|
|
106
|
+
const bounded = firstNewline >= 0 ? completeTail.subarray(firstNewline + 1) : completeTail;
|
|
107
|
+
|
|
108
|
+
temporary = `${path}.${randomUUID()}.tmp`;
|
|
109
|
+
temporaryDescriptor = openSync(
|
|
110
|
+
temporary,
|
|
111
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow,
|
|
112
|
+
0o600,
|
|
113
|
+
);
|
|
114
|
+
if (!fstatSync(temporaryDescriptor).isFile()) {
|
|
115
|
+
throw Object.assign(new Error('OpenCode diagnostic temporary is not a regular file'), { code: 'EINVAL' });
|
|
116
|
+
}
|
|
117
|
+
fchmodSync(temporaryDescriptor, 0o600);
|
|
118
|
+
let bytesWritten = 0;
|
|
119
|
+
while (bytesWritten < bounded.length) {
|
|
120
|
+
const count = writeSync(
|
|
121
|
+
temporaryDescriptor,
|
|
122
|
+
bounded,
|
|
123
|
+
bytesWritten,
|
|
124
|
+
bounded.length - bytesWritten,
|
|
125
|
+
);
|
|
126
|
+
if (count === 0) throw Object.assign(new Error('OpenCode diagnostic temporary write stalled'), { code: 'EIO' });
|
|
127
|
+
bytesWritten += count;
|
|
128
|
+
}
|
|
129
|
+
closeSync(temporaryDescriptor);
|
|
130
|
+
temporaryDescriptor = null;
|
|
131
|
+
closeSync(descriptor);
|
|
132
|
+
descriptor = null;
|
|
133
|
+
renameSync(temporary, path);
|
|
134
|
+
temporary = null;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const code = (error as NodeJS.ErrnoException | null)?.code ?? 'unknown';
|
|
137
|
+
process.stderr.write(`OpenCode diagnostic log write failed (${code})\n`);
|
|
138
|
+
if (throwOnFailure) throw error;
|
|
139
|
+
} finally {
|
|
140
|
+
if (temporaryDescriptor !== null) {
|
|
141
|
+
try { closeSync(temporaryDescriptor); } catch { /* The primary write error is already reported. */ }
|
|
142
|
+
}
|
|
143
|
+
if (descriptor !== null) {
|
|
144
|
+
try { closeSync(descriptor); } catch { /* The primary write error is already reported. */ }
|
|
145
|
+
}
|
|
146
|
+
if (temporary !== null) {
|
|
147
|
+
try {
|
|
148
|
+
unlinkSync(temporary);
|
|
149
|
+
} catch {
|
|
150
|
+
// Already absent or inaccessible; the randomized name cannot be reused.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function writeOpenCodeStartupDiagnostic(message: string): void {
|
|
157
|
+
log(message, null, true);
|
|
22
158
|
}
|
|
23
159
|
|
|
24
160
|
interface OpenCodeDroneState {
|
|
@@ -41,8 +177,25 @@ interface OpenCodeDroneState {
|
|
|
41
177
|
pendingSubmissions: Map<string, PendingOpenCodeSubmission>;
|
|
42
178
|
reconcilingEntryIds: Set<string>;
|
|
43
179
|
processingDeliveries: boolean;
|
|
180
|
+
nextObservationSequence: number;
|
|
181
|
+
lastObservation: OpenCodeLastObservation;
|
|
44
182
|
}
|
|
45
183
|
|
|
184
|
+
interface OpenCodeLastObservation {
|
|
185
|
+
injectionSequence: number;
|
|
186
|
+
acceptedSequence: number;
|
|
187
|
+
failureSequence: number;
|
|
188
|
+
lastInjectionAt: number | null;
|
|
189
|
+
lastInjectionResult: OpenCodeInjectionResult | null;
|
|
190
|
+
lastAcceptedEntryId: string | null;
|
|
191
|
+
lastFailureCode: string | null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type OpenCodeLastFields = Pick<
|
|
195
|
+
OpenCodeLastObservation,
|
|
196
|
+
'lastInjectionAt' | 'lastInjectionResult' | 'lastAcceptedEntryId' | 'lastFailureCode'
|
|
197
|
+
>;
|
|
198
|
+
|
|
46
199
|
let state: OpenCodeDroneState | null = null;
|
|
47
200
|
|
|
48
201
|
interface ConnectDeps {
|
|
@@ -63,12 +216,12 @@ interface OCSession {
|
|
|
63
216
|
}
|
|
64
217
|
|
|
65
218
|
interface OCMessage {
|
|
66
|
-
info
|
|
219
|
+
info: {
|
|
67
220
|
id?: string;
|
|
68
|
-
role
|
|
221
|
+
role: string;
|
|
69
222
|
time?: { created?: number };
|
|
70
223
|
};
|
|
71
|
-
parts
|
|
224
|
+
parts: Array<{
|
|
72
225
|
type?: string;
|
|
73
226
|
text?: string;
|
|
74
227
|
metadata?: Record<string, unknown>;
|
|
@@ -82,6 +235,7 @@ export type OpenCodeDeliveryState =
|
|
|
82
235
|
| 'failed';
|
|
83
236
|
|
|
84
237
|
interface OpenCodeDelivery {
|
|
238
|
+
sequence: number;
|
|
85
239
|
entryId: string;
|
|
86
240
|
sourceEntryId: string;
|
|
87
241
|
text: string;
|
|
@@ -107,6 +261,7 @@ interface PendingOpenCodeSubmission {
|
|
|
107
261
|
}
|
|
108
262
|
|
|
109
263
|
type OpenCodeDeliveryOutcome = 'delivered' | 'delivered-unconfirmed' | 'failed';
|
|
264
|
+
type OpenCodeInjectionResult = OpenCodeDeliveryOutcome;
|
|
110
265
|
|
|
111
266
|
const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const;
|
|
112
267
|
const OPEN_CODE_RECONCILIATION_DELAY_MS = 3_000;
|
|
@@ -168,8 +323,9 @@ function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
|
|
|
168
323
|
|
|
169
324
|
export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
|
|
170
325
|
if (!isOpenCode256BitIdentity(deps.apiPassword)) {
|
|
171
|
-
throw new
|
|
326
|
+
throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
|
|
172
327
|
}
|
|
328
|
+
await ensurePrivateBorgConfigRoot(borgConfigRoot());
|
|
173
329
|
abandonOpenCodeDeliveries(state);
|
|
174
330
|
state = {
|
|
175
331
|
serverUrl: deps.serverUrl,
|
|
@@ -191,8 +347,18 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
|
|
|
191
347
|
pendingSubmissions: new Map(),
|
|
192
348
|
reconcilingEntryIds: new Set(),
|
|
193
349
|
processingDeliveries: false,
|
|
350
|
+
nextObservationSequence: 0,
|
|
351
|
+
lastObservation: {
|
|
352
|
+
injectionSequence: 0,
|
|
353
|
+
acceptedSequence: 0,
|
|
354
|
+
failureSequence: 0,
|
|
355
|
+
lastInjectionAt: null,
|
|
356
|
+
lastInjectionResult: null,
|
|
357
|
+
lastAcceptedEntryId: null,
|
|
358
|
+
lastFailureCode: null,
|
|
359
|
+
},
|
|
194
360
|
};
|
|
195
|
-
log(`connected url=${deps.serverUrl} dir=${deps.directory}
|
|
361
|
+
log(`connected url=${deps.serverUrl} dir=${deps.directory}`, state);
|
|
196
362
|
}
|
|
197
363
|
|
|
198
364
|
// ---------------------------------------------------------------------------
|
|
@@ -207,7 +373,7 @@ function apiUrl(path: string): string {
|
|
|
207
373
|
function authenticatedHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
|
208
374
|
const password = state?.apiPassword;
|
|
209
375
|
if (!isOpenCode256BitIdentity(password)) {
|
|
210
|
-
throw new
|
|
376
|
+
throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
|
|
211
377
|
}
|
|
212
378
|
return {
|
|
213
379
|
...headers,
|
|
@@ -225,6 +391,13 @@ async function rawGet(path: string): Promise<{ status: number; body: string }> {
|
|
|
225
391
|
const res = await fetch(url, { headers: authenticatedHeaders(), signal: controller.signal });
|
|
226
392
|
const body = await res.text();
|
|
227
393
|
return { status: res.status, body };
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (error instanceof OpenCodeAuthenticationError) throw error;
|
|
396
|
+
throw new OpenCodeUnreachableError(
|
|
397
|
+
controller.signal.aborted ? 'timeout' : 'transient',
|
|
398
|
+
controller.signal.aborted ? 'OpenCode request timed out' : 'OpenCode request failed',
|
|
399
|
+
{ cause: error },
|
|
400
|
+
);
|
|
228
401
|
} finally {
|
|
229
402
|
clearTimeout(timer);
|
|
230
403
|
}
|
|
@@ -243,28 +416,111 @@ async function rawPost(path: string, bodyObj: unknown): Promise<{ status: number
|
|
|
243
416
|
});
|
|
244
417
|
const body = await res.text();
|
|
245
418
|
return { status: res.status, body };
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error instanceof OpenCodeAuthenticationError) throw error;
|
|
421
|
+
throw new OpenCodeUnreachableError(
|
|
422
|
+
controller.signal.aborted ? 'timeout' : 'transient',
|
|
423
|
+
controller.signal.aborted ? 'OpenCode request timed out' : 'OpenCode request failed',
|
|
424
|
+
{ cause: error },
|
|
425
|
+
);
|
|
246
426
|
} finally {
|
|
247
427
|
clearTimeout(timer);
|
|
248
428
|
}
|
|
249
429
|
}
|
|
250
430
|
|
|
431
|
+
function openCodeHttpError(status: number, operation: string): OpenCodeHttpError {
|
|
432
|
+
const code: OpenCodeFailureCode = status === 401
|
|
433
|
+
? 'unauthorized'
|
|
434
|
+
: status === 404
|
|
435
|
+
? 'not-found'
|
|
436
|
+
: status >= 500 || status === 429
|
|
437
|
+
? 'transient'
|
|
438
|
+
: 'incompatible-api';
|
|
439
|
+
return new OpenCodeHttpError(status, code, `OpenCode ${operation} request failed (${status})`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function parseOpenCodeJson(body: string): unknown {
|
|
443
|
+
try {
|
|
444
|
+
return JSON.parse(body);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
throw new OpenCodeResponseError('OpenCode returned malformed JSON', { cause: error });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
451
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function decodeSession(value: unknown): OCSession {
|
|
455
|
+
if (
|
|
456
|
+
!isRecord(value)
|
|
457
|
+
|| typeof value.id !== 'string'
|
|
458
|
+
|| value.id.length === 0
|
|
459
|
+
|| typeof value.directory !== 'string'
|
|
460
|
+
|| !isRecord(value.time)
|
|
461
|
+
|| typeof value.time.created !== 'number'
|
|
462
|
+
|| !Number.isFinite(value.time.created)
|
|
463
|
+
|| (value.parentID !== undefined && typeof value.parentID !== 'string')
|
|
464
|
+
|| (value.agent !== undefined && typeof value.agent !== 'string')
|
|
465
|
+
|| (value.model !== undefined && (
|
|
466
|
+
!isRecord(value.model)
|
|
467
|
+
|| typeof value.model.providerID !== 'string'
|
|
468
|
+
|| typeof value.model.modelID !== 'string'
|
|
469
|
+
))
|
|
470
|
+
) {
|
|
471
|
+
throw new OpenCodeResponseError();
|
|
472
|
+
}
|
|
473
|
+
return value as unknown as OCSession;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function decodeSessions(body: string): OCSession[] {
|
|
477
|
+
const value = parseOpenCodeJson(body);
|
|
478
|
+
if (!Array.isArray(value)) throw new OpenCodeResponseError();
|
|
479
|
+
return value.map(decodeSession);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function decodeMessages(body: string): OCMessage[] {
|
|
483
|
+
const value = parseOpenCodeJson(body);
|
|
484
|
+
if (!Array.isArray(value)) throw new OpenCodeResponseError();
|
|
485
|
+
return value.map((message) => {
|
|
486
|
+
if (!isRecord(message)) throw new OpenCodeResponseError();
|
|
487
|
+
if (
|
|
488
|
+
!isRecord(message.info)
|
|
489
|
+
|| typeof message.info.role !== 'string'
|
|
490
|
+
|| (message.info.id !== undefined && typeof message.info.id !== 'string')
|
|
491
|
+
|| (message.info.time !== undefined && (
|
|
492
|
+
!isRecord(message.info.time)
|
|
493
|
+
|| (message.info.time.created !== undefined && typeof message.info.time.created !== 'number')
|
|
494
|
+
))
|
|
495
|
+
) throw new OpenCodeResponseError();
|
|
496
|
+
if (
|
|
497
|
+
!Array.isArray(message.parts)
|
|
498
|
+
|| message.parts.some((part) => !isRecord(part)
|
|
499
|
+
|| (part.type !== undefined && typeof part.type !== 'string')
|
|
500
|
+
|| (part.text !== undefined && typeof part.text !== 'string')
|
|
501
|
+
|| (part.metadata !== undefined && !isRecord(part.metadata)))
|
|
502
|
+
) throw new OpenCodeResponseError();
|
|
503
|
+
return message as unknown as OCMessage;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
251
507
|
async function listSessions(): Promise<OCSession[]> {
|
|
252
508
|
const { status, body } = await rawGet('/session');
|
|
253
|
-
if (status !== 200) throw
|
|
254
|
-
return
|
|
509
|
+
if (status !== 200) throw openCodeHttpError(status, 'sessions');
|
|
510
|
+
return decodeSessions(body);
|
|
255
511
|
}
|
|
256
512
|
|
|
257
513
|
async function getSession(id: string): Promise<OCSession | null> {
|
|
258
514
|
const { status, body } = await rawGet(`/session/${id}`);
|
|
259
515
|
if (status === 404) return null;
|
|
260
|
-
if (status !== 200) throw
|
|
261
|
-
return
|
|
516
|
+
if (status !== 200) throw openCodeHttpError(status, 'session');
|
|
517
|
+
return decodeSession(parseOpenCodeJson(body));
|
|
262
518
|
}
|
|
263
519
|
|
|
264
520
|
async function listSessionMessages(id: string): Promise<OCMessage[]> {
|
|
265
521
|
const { status, body } = await rawGet(`/session/${id}/message`);
|
|
266
|
-
if (status !== 200) throw
|
|
267
|
-
return
|
|
522
|
+
if (status !== 200) throw openCodeHttpError(status, 'session messages');
|
|
523
|
+
return decodeMessages(body);
|
|
268
524
|
}
|
|
269
525
|
|
|
270
526
|
async function findInjectedMessage(
|
|
@@ -298,9 +554,7 @@ async function promptSession(id: string, bodyObj: Record<string, unknown>): Prom
|
|
|
298
554
|
|
|
299
555
|
function bindingPath(): string {
|
|
300
556
|
const current = state!;
|
|
301
|
-
const
|
|
302
|
-
const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
|
|
303
|
-
const path = join(tmpdir(), `borg-opencode-session-${digest}.json`);
|
|
557
|
+
const path = join(tmpdir(), `borg-opencode-session-${stateIdentityDigest(current)}.json`);
|
|
304
558
|
bindingPathsForTests.add(path);
|
|
305
559
|
return path;
|
|
306
560
|
}
|
|
@@ -446,32 +700,28 @@ function restoreBinding(): SessionBinding | null {
|
|
|
446
700
|
}
|
|
447
701
|
|
|
448
702
|
function isBoundSession(session: OCSession, binding: SessionBinding): boolean {
|
|
449
|
-
return session.id === binding.sessionId && session.directory ===
|
|
703
|
+
return session.id === binding.sessionId && session.directory === binding.directory;
|
|
450
704
|
}
|
|
451
705
|
|
|
452
706
|
function isTopLevelSession(session: OCSession): boolean {
|
|
453
707
|
return !session.parentID;
|
|
454
708
|
}
|
|
455
709
|
|
|
456
|
-
async function findUnseenTopLevelSession(knownRootSessionIds: string[]): Promise<{
|
|
710
|
+
async function findUnseenTopLevelSession(knownRootSessionIds: string[], directory: string): Promise<{
|
|
457
711
|
session: OCSession;
|
|
458
712
|
knownRootSessionIds: string[];
|
|
459
713
|
} | null> {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
(session)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
return { session: best, knownRootSessionIds: roots.map((session) => session.id) };
|
|
472
|
-
} catch {
|
|
473
|
-
return null;
|
|
474
|
-
}
|
|
714
|
+
const sessions = await listSessions();
|
|
715
|
+
const roots = sessions.filter(
|
|
716
|
+
(session) => session.directory === directory
|
|
717
|
+
&& isTopLevelSession(session),
|
|
718
|
+
);
|
|
719
|
+
const matched = roots.filter((session) => !knownRootSessionIds.includes(session.id));
|
|
720
|
+
if (matched.length === 0) return null;
|
|
721
|
+
const best = matched.reduce((a, b) =>
|
|
722
|
+
a.time.created > b.time.created ? a : b,
|
|
723
|
+
);
|
|
724
|
+
return { session: best, knownRootSessionIds: roots.map((session) => session.id) };
|
|
475
725
|
}
|
|
476
726
|
|
|
477
727
|
function launchCorrelationMatchCount(messages: OCMessage[], correlationIdentity: string): number {
|
|
@@ -500,9 +750,13 @@ async function findLaunchSession(correlationIdentity: string): Promise<{
|
|
|
500
750
|
session: OCSession;
|
|
501
751
|
knownRootSessionIds: string[];
|
|
502
752
|
} | null> {
|
|
753
|
+
const owner = state!;
|
|
754
|
+
const observationSequence = ++owner.nextObservationSequence;
|
|
503
755
|
try {
|
|
504
|
-
const
|
|
505
|
-
|
|
756
|
+
const listedSessions = await listSessions();
|
|
757
|
+
if (state !== owner) return null;
|
|
758
|
+
const sessions = listedSessions.filter(
|
|
759
|
+
(session) => session.directory === owner.directory,
|
|
506
760
|
);
|
|
507
761
|
const knownRootSessionIds = sessions
|
|
508
762
|
.filter(isTopLevelSession)
|
|
@@ -514,23 +768,31 @@ async function findLaunchSession(correlationIdentity: string): Promise<{
|
|
|
514
768
|
correlationIdentity,
|
|
515
769
|
),
|
|
516
770
|
})));
|
|
771
|
+
if (state !== owner) return null;
|
|
517
772
|
const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
|
|
518
773
|
if (totalMatches !== 1) return null;
|
|
519
774
|
const matched = candidates.find((candidate) => candidate.matchCount === 1);
|
|
520
775
|
return matched ? { session: matched.session, knownRootSessionIds } : null;
|
|
521
|
-
} catch {
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (state === owner) recordOpenCodeFailure(owner, error, observationSequence);
|
|
522
778
|
return null;
|
|
523
779
|
}
|
|
524
780
|
}
|
|
525
781
|
|
|
526
|
-
async function resolveInjectionSession(
|
|
782
|
+
async function resolveInjectionSession(
|
|
783
|
+
owner: OpenCodeDroneState,
|
|
784
|
+
observationSequence: number,
|
|
785
|
+
): Promise<OCSession | null> {
|
|
786
|
+
if (state !== owner) return null;
|
|
527
787
|
const binding = restoreBinding();
|
|
528
788
|
if (!binding) return null;
|
|
529
789
|
|
|
530
790
|
const bound = await getSession(binding.sessionId);
|
|
791
|
+
if (state !== owner) return null;
|
|
531
792
|
if (!bound || !isBoundSession(bound, binding)) {
|
|
532
793
|
clearBinding();
|
|
533
|
-
const replacement = await findUnseenTopLevelSession(binding.knownRootSessionIds);
|
|
794
|
+
const replacement = await findUnseenTopLevelSession(binding.knownRootSessionIds, owner.directory);
|
|
795
|
+
if (state !== owner) return null;
|
|
534
796
|
if (!replacement) return null;
|
|
535
797
|
saveBinding(replacement.session, replacement.knownRootSessionIds);
|
|
536
798
|
return replacement.session;
|
|
@@ -539,7 +801,13 @@ async function resolveInjectionSession(): Promise<OCSession | null> {
|
|
|
539
801
|
// `/new` creates an unseen top-level session. Keep the launch-time root
|
|
540
802
|
// snapshot so an old, unrelated root is never mistaken for a user switch.
|
|
541
803
|
// Children never supersede the bound root.
|
|
542
|
-
|
|
804
|
+
let switched: Awaited<ReturnType<typeof findUnseenTopLevelSession>> = null;
|
|
805
|
+
try {
|
|
806
|
+
switched = await findUnseenTopLevelSession(binding.knownRootSessionIds, owner.directory);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
recordOpenCodeFailure(owner, error, observationSequence);
|
|
809
|
+
}
|
|
810
|
+
if (state !== owner) return null;
|
|
543
811
|
if (switched) {
|
|
544
812
|
saveBinding(switched.session, switched.knownRootSessionIds);
|
|
545
813
|
return switched.session;
|
|
@@ -562,6 +830,64 @@ function rememberBounded(
|
|
|
562
830
|
}
|
|
563
831
|
}
|
|
564
832
|
|
|
833
|
+
function openCodeFailureCode(error: unknown): string {
|
|
834
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
835
|
+
if (typeof code === 'string' && code.length > 0) return code;
|
|
836
|
+
return error instanceof Error && error.name ? error.name : 'unknown';
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function updateLastOpenCodeObservation(
|
|
840
|
+
owner: OpenCodeDroneState,
|
|
841
|
+
sequence: number,
|
|
842
|
+
update: Partial<OpenCodeLastFields>,
|
|
843
|
+
): void {
|
|
844
|
+
// Attempts, acceptances, and failures resolve independently; an observation
|
|
845
|
+
// may be stale for one field without being stale for the others.
|
|
846
|
+
const current = owner.lastObservation;
|
|
847
|
+
const updatesInjection = 'lastInjectionAt' in update || 'lastInjectionResult' in update;
|
|
848
|
+
const updatesAccepted = 'lastAcceptedEntryId' in update;
|
|
849
|
+
const updatesFailure = 'lastFailureCode' in update;
|
|
850
|
+
owner.lastObservation = {
|
|
851
|
+
...current,
|
|
852
|
+
...(updatesInjection && sequence >= current.injectionSequence
|
|
853
|
+
? {
|
|
854
|
+
injectionSequence: sequence,
|
|
855
|
+
...('lastInjectionAt' in update
|
|
856
|
+
? { lastInjectionAt: update.lastInjectionAt as number | null }
|
|
857
|
+
: {}),
|
|
858
|
+
...('lastInjectionResult' in update
|
|
859
|
+
? { lastInjectionResult: update.lastInjectionResult as OpenCodeInjectionResult | null }
|
|
860
|
+
: {}),
|
|
861
|
+
}
|
|
862
|
+
: {}),
|
|
863
|
+
...(updatesAccepted && sequence >= current.acceptedSequence
|
|
864
|
+
? { acceptedSequence: sequence, lastAcceptedEntryId: update.lastAcceptedEntryId as string | null }
|
|
865
|
+
: {}),
|
|
866
|
+
...(updatesFailure && sequence >= current.failureSequence
|
|
867
|
+
? { failureSequence: sequence, lastFailureCode: update.lastFailureCode as string | null }
|
|
868
|
+
: {}),
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function recordOpenCodeFailure(
|
|
873
|
+
owner: OpenCodeDroneState,
|
|
874
|
+
error: unknown,
|
|
875
|
+
observationSequence: number,
|
|
876
|
+
): void {
|
|
877
|
+
updateLastOpenCodeObservation(owner, observationSequence, {
|
|
878
|
+
lastFailureCode: openCodeFailureCode(error),
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function recordOpenCodeAcceptance(
|
|
883
|
+
owner: OpenCodeDroneState,
|
|
884
|
+
delivery: Pick<OpenCodeDelivery, 'sequence' | 'entryId'>,
|
|
885
|
+
): void {
|
|
886
|
+
updateLastOpenCodeObservation(owner, delivery.sequence, {
|
|
887
|
+
lastAcceptedEntryId: delivery.entryId,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
565
891
|
function clearPendingSubmission(owner: OpenCodeDroneState, entryId: string): void {
|
|
566
892
|
if (!owner.pendingSubmissions.delete(entryId)) return;
|
|
567
893
|
if (state === owner) persistCurrentBinding();
|
|
@@ -569,7 +895,7 @@ function clearPendingSubmission(owner: OpenCodeDroneState, entryId: string): voi
|
|
|
569
895
|
|
|
570
896
|
function confirmOpenCodeDelivery(
|
|
571
897
|
owner: OpenCodeDroneState,
|
|
572
|
-
delivery: Pick<OpenCodeDelivery, 'entryId' | 'sourceEntryId' | 'text'>,
|
|
898
|
+
delivery: Pick<OpenCodeDelivery, 'sequence' | 'entryId' | 'sourceEntryId' | 'text'>,
|
|
573
899
|
): void {
|
|
574
900
|
const unconfirmed = owner.unconfirmedEntries.get(delivery.entryId);
|
|
575
901
|
if (unconfirmed && unconfirmed.text !== delivery.text) return;
|
|
@@ -583,6 +909,11 @@ function confirmOpenCodeDelivery(
|
|
|
583
909
|
delivery.sourceEntryId,
|
|
584
910
|
);
|
|
585
911
|
owner.totalEntriesInjected++;
|
|
912
|
+
updateLastOpenCodeObservation(owner, delivery.sequence, {
|
|
913
|
+
lastAcceptedEntryId: delivery.entryId,
|
|
914
|
+
lastInjectionResult: 'delivered',
|
|
915
|
+
lastFailureCode: null,
|
|
916
|
+
});
|
|
586
917
|
}
|
|
587
918
|
|
|
588
919
|
function scheduleOpenCodeReconciliation(
|
|
@@ -607,7 +938,8 @@ function scheduleOpenCodeReconciliation(
|
|
|
607
938
|
return;
|
|
608
939
|
}
|
|
609
940
|
} catch (err) {
|
|
610
|
-
|
|
941
|
+
recordOpenCodeFailure(owner, err, delivery.sequence);
|
|
942
|
+
log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`, owner);
|
|
611
943
|
}
|
|
612
944
|
}
|
|
613
945
|
} finally {
|
|
@@ -656,13 +988,15 @@ async function deliverOpenCodeEntry(
|
|
|
656
988
|
|
|
657
989
|
if (!target) {
|
|
658
990
|
try {
|
|
659
|
-
target = await resolveInjectionSession();
|
|
991
|
+
target = await resolveInjectionSession(owner, delivery.sequence);
|
|
660
992
|
} catch (err) {
|
|
661
|
-
|
|
993
|
+
recordOpenCodeFailure(owner, err, delivery.sequence);
|
|
994
|
+
log(`entry ${delivery.entryId} target unavailable: ${err}`, owner);
|
|
662
995
|
continue;
|
|
663
996
|
}
|
|
664
997
|
if (!target) {
|
|
665
|
-
|
|
998
|
+
recordOpenCodeFailure(owner, openCodeHttpError(404, 'session'), delivery.sequence);
|
|
999
|
+
log(`entry ${delivery.entryId} target unavailable: no bound session`, owner);
|
|
666
1000
|
return 'failed';
|
|
667
1001
|
}
|
|
668
1002
|
delivery.sessionId = target.id;
|
|
@@ -679,12 +1013,13 @@ async function deliverOpenCodeEntry(
|
|
|
679
1013
|
: await findInjectedMessage(confirmationSessionId, delivery.sourceEntryId)
|
|
680
1014
|
);
|
|
681
1015
|
if (deliveredIdentity) {
|
|
682
|
-
log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}
|
|
1016
|
+
log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}`, owner);
|
|
683
1017
|
clearPendingSubmission(owner, delivery.entryId);
|
|
684
1018
|
return 'delivered';
|
|
685
1019
|
}
|
|
686
1020
|
} catch (err) {
|
|
687
|
-
|
|
1021
|
+
recordOpenCodeFailure(owner, err, delivery.sequence);
|
|
1022
|
+
log(`entry ${delivery.entryId} confirmation unavailable: ${err}`, owner);
|
|
688
1023
|
continue;
|
|
689
1024
|
}
|
|
690
1025
|
|
|
@@ -705,7 +1040,7 @@ async function deliverOpenCodeEntry(
|
|
|
705
1040
|
});
|
|
706
1041
|
if (!persistCurrentBinding()) {
|
|
707
1042
|
owner.pendingSubmissions.delete(delivery.entryId);
|
|
708
|
-
log(`entry ${delivery.entryId} submission skipped: pending intent was not durable
|
|
1043
|
+
log(`entry ${delivery.entryId} submission skipped: pending intent was not durable`, owner);
|
|
709
1044
|
return 'failed';
|
|
710
1045
|
}
|
|
711
1046
|
|
|
@@ -724,16 +1059,19 @@ async function deliverOpenCodeEntry(
|
|
|
724
1059
|
}],
|
|
725
1060
|
});
|
|
726
1061
|
} catch (err) {
|
|
727
|
-
|
|
1062
|
+
recordOpenCodeFailure(owner, err, delivery.sequence);
|
|
1063
|
+
log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`, owner);
|
|
728
1064
|
}
|
|
729
1065
|
|
|
730
1066
|
delivery.state = 'delivered-unconfirmed';
|
|
731
1067
|
if (status !== null && status !== 200 && status !== 204) {
|
|
1068
|
+
recordOpenCodeFailure(owner, openCodeHttpError(status, 'prompt'), delivery.sequence);
|
|
732
1069
|
clearPendingSubmission(owner, delivery.entryId);
|
|
733
1070
|
if (status === 404) clearBinding();
|
|
734
1071
|
return 'failed';
|
|
735
1072
|
}
|
|
736
1073
|
delivery.acceptedSubmission = true;
|
|
1074
|
+
if (status === 200 || status === 204) recordOpenCodeAcceptance(owner, delivery);
|
|
737
1075
|
}
|
|
738
1076
|
|
|
739
1077
|
for (
|
|
@@ -755,7 +1093,8 @@ async function deliverOpenCodeEntry(
|
|
|
755
1093
|
return 'delivered';
|
|
756
1094
|
}
|
|
757
1095
|
} catch (err) {
|
|
758
|
-
|
|
1096
|
+
recordOpenCodeFailure(owner, err, delivery.sequence);
|
|
1097
|
+
log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`, owner);
|
|
759
1098
|
}
|
|
760
1099
|
}
|
|
761
1100
|
|
|
@@ -774,12 +1113,26 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
|
|
|
774
1113
|
try {
|
|
775
1114
|
while (state === owner && owner.deliveryQueue.length > 0) {
|
|
776
1115
|
const delivery = owner.deliveryQueue.shift()!;
|
|
1116
|
+
updateLastOpenCodeObservation(owner, delivery.sequence, {
|
|
1117
|
+
lastInjectionAt: Date.now(),
|
|
1118
|
+
lastInjectionResult: null,
|
|
1119
|
+
lastFailureCode: null,
|
|
1120
|
+
});
|
|
777
1121
|
let outcome: OpenCodeDeliveryOutcome = 'failed';
|
|
778
1122
|
try {
|
|
779
1123
|
outcome = await deliverOpenCodeEntry(owner, delivery);
|
|
780
1124
|
} catch (err) {
|
|
781
|
-
|
|
1125
|
+
recordOpenCodeFailure(owner, err, delivery.sequence);
|
|
1126
|
+
log(`entry ${delivery.entryId} delivery error: ${err}`, owner);
|
|
782
1127
|
}
|
|
1128
|
+
updateLastOpenCodeObservation(owner, delivery.sequence, {
|
|
1129
|
+
lastInjectionResult: outcome,
|
|
1130
|
+
lastFailureCode: outcome === 'delivered'
|
|
1131
|
+
? null
|
|
1132
|
+
: outcome === 'failed'
|
|
1133
|
+
? (owner.lastObservation.lastFailureCode ?? 'unknown')
|
|
1134
|
+
: owner.lastObservation.lastFailureCode,
|
|
1135
|
+
});
|
|
783
1136
|
|
|
784
1137
|
owner.activeDeliveries.delete(delivery.entryId);
|
|
785
1138
|
if (delivery.settled) {
|
|
@@ -823,9 +1176,10 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
|
|
|
823
1176
|
* the separate MCP-child process, which must never fall back to a newest-session heuristic.
|
|
824
1177
|
*/
|
|
825
1178
|
export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promise<boolean> {
|
|
826
|
-
|
|
1179
|
+
const owner = state;
|
|
1180
|
+
if (!owner?.connected) { log('kickoff: not connected', owner); return false; }
|
|
827
1181
|
if (!isOpenCode256BitIdentity(launch.correlationIdentity)) {
|
|
828
|
-
log('kickoff: correlation identity missing or unverifiable');
|
|
1182
|
+
log('kickoff: correlation identity missing or unverifiable', owner);
|
|
829
1183
|
return false;
|
|
830
1184
|
}
|
|
831
1185
|
|
|
@@ -834,7 +1188,7 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
|
|
|
834
1188
|
for (let i = 0; i < 30; i++) {
|
|
835
1189
|
try {
|
|
836
1190
|
await listSessions();
|
|
837
|
-
log(`kickoff: server ready (attempt ${i + 1})
|
|
1191
|
+
log(`kickoff: server ready (attempt ${i + 1})`, owner);
|
|
838
1192
|
break;
|
|
839
1193
|
} catch {
|
|
840
1194
|
// not ready yet
|
|
@@ -847,17 +1201,18 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
|
|
|
847
1201
|
for (let i = 0; i < 30; i++) {
|
|
848
1202
|
const binding = await findLaunchSession(launch.correlationIdentity);
|
|
849
1203
|
if (binding) {
|
|
1204
|
+
if (state !== owner) return false;
|
|
850
1205
|
saveBinding(binding.session, binding.knownRootSessionIds);
|
|
851
|
-
log(`kickoff: bound session ${binding.session.id.slice(0, 8)}
|
|
1206
|
+
log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`, owner);
|
|
852
1207
|
return true;
|
|
853
1208
|
}
|
|
854
1209
|
await new Promise((r) => setTimeout(r, 1000));
|
|
855
1210
|
}
|
|
856
1211
|
|
|
857
|
-
log('kickoff: no session found');
|
|
1212
|
+
log('kickoff: no session found', owner);
|
|
858
1213
|
return false;
|
|
859
1214
|
} catch (err) {
|
|
860
|
-
log(`kickoff error: ${err}
|
|
1215
|
+
log(`kickoff error: ${err}`, owner);
|
|
861
1216
|
return false;
|
|
862
1217
|
}
|
|
863
1218
|
}
|
|
@@ -879,7 +1234,7 @@ export function injectOpenCodeEntry(
|
|
|
879
1234
|
): Promise<boolean> {
|
|
880
1235
|
const owner = state;
|
|
881
1236
|
if (!owner?.connected) {
|
|
882
|
-
log(`entry ${entryId} rejected: OpenCode is not connected
|
|
1237
|
+
log(`entry ${entryId} rejected: OpenCode is not connected`, owner);
|
|
883
1238
|
return Promise.resolve(false);
|
|
884
1239
|
}
|
|
885
1240
|
|
|
@@ -893,27 +1248,27 @@ export function injectOpenCodeEntry(
|
|
|
893
1248
|
pendingEntryId !== entryId && pending.sourceEntryId === sourceEntryId,
|
|
894
1249
|
);
|
|
895
1250
|
if (pendingSource) {
|
|
896
|
-
log(`entry ${entryId} reconciles pending source ${sourceEntryId}
|
|
1251
|
+
log(`entry ${entryId} reconciles pending source ${sourceEntryId}`, owner);
|
|
897
1252
|
return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId, isSourcePending);
|
|
898
1253
|
}
|
|
899
1254
|
for (const [deliveredEntryId, record] of owner.deliveredEntries) {
|
|
900
1255
|
if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
|
|
901
1256
|
if (record.text !== text) return Promise.resolve(false);
|
|
902
|
-
log(`entry ${entryId} source ${sourceEntryId} already delivered
|
|
1257
|
+
log(`entry ${entryId} source ${sourceEntryId} already delivered`, owner);
|
|
903
1258
|
return Promise.resolve(true);
|
|
904
1259
|
}
|
|
905
1260
|
}
|
|
906
1261
|
for (const [unconfirmedEntryId, record] of owner.unconfirmedEntries) {
|
|
907
1262
|
if (unconfirmedEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
|
|
908
1263
|
if (record.text !== text) return Promise.resolve(false);
|
|
909
|
-
log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed
|
|
1264
|
+
log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed`, owner);
|
|
910
1265
|
return Promise.resolve(true);
|
|
911
1266
|
}
|
|
912
1267
|
}
|
|
913
1268
|
for (const active of owner.activeDeliveries.values()) {
|
|
914
1269
|
if (active.entryId !== entryId && active.sourceEntryId === sourceEntryId) {
|
|
915
1270
|
if (active.text !== text) return Promise.resolve(false);
|
|
916
|
-
log(`entry ${entryId} joined active source ${sourceEntryId}
|
|
1271
|
+
log(`entry ${entryId} joined active source ${sourceEntryId}`, owner);
|
|
917
1272
|
return active.promise;
|
|
918
1273
|
}
|
|
919
1274
|
}
|
|
@@ -921,26 +1276,27 @@ export function injectOpenCodeEntry(
|
|
|
921
1276
|
const delivered = owner.deliveredEntries.get(entryId);
|
|
922
1277
|
if (delivered !== undefined) {
|
|
923
1278
|
if (delivered.text !== text || delivered.sourceEntryId !== sourceEntryId) {
|
|
924
|
-
log(`entry ${entryId} replay text mismatch
|
|
1279
|
+
log(`entry ${entryId} replay text mismatch`, owner);
|
|
925
1280
|
rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
|
|
926
1281
|
return Promise.resolve(false);
|
|
927
1282
|
}
|
|
928
|
-
log(`entry ${entryId} replay already delivered
|
|
1283
|
+
log(`entry ${entryId} replay already delivered`, owner);
|
|
929
1284
|
return Promise.resolve(true);
|
|
930
1285
|
}
|
|
931
1286
|
|
|
932
1287
|
const unconfirmed = owner.unconfirmedEntries.get(entryId);
|
|
933
1288
|
if (unconfirmed !== undefined) {
|
|
934
1289
|
if (unconfirmed.text !== text || unconfirmed.sourceEntryId !== sourceEntryId) {
|
|
935
|
-
log(`entry ${entryId} unconfirmed replay text mismatch
|
|
1290
|
+
log(`entry ${entryId} unconfirmed replay text mismatch`, owner);
|
|
936
1291
|
rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
|
|
937
1292
|
return Promise.resolve(false);
|
|
938
1293
|
}
|
|
939
|
-
log(`entry ${entryId} replay remains unconfirmed
|
|
1294
|
+
log(`entry ${entryId} replay remains unconfirmed`, owner);
|
|
940
1295
|
const pending = owner.pendingSubmissions.get(entryId);
|
|
941
1296
|
const accepted = pending !== undefined;
|
|
942
1297
|
if (pending) {
|
|
943
1298
|
scheduleOpenCodeReconciliation(owner, {
|
|
1299
|
+
sequence: ++owner.nextObservationSequence,
|
|
944
1300
|
entryId,
|
|
945
1301
|
sourceEntryId,
|
|
946
1302
|
text,
|
|
@@ -959,11 +1315,11 @@ export function injectOpenCodeEntry(
|
|
|
959
1315
|
const active = owner.activeDeliveries.get(entryId);
|
|
960
1316
|
if (active) {
|
|
961
1317
|
if (active.text !== text || active.sourceEntryId !== sourceEntryId) {
|
|
962
|
-
log(`entry ${entryId} active text mismatch
|
|
1318
|
+
log(`entry ${entryId} active text mismatch`, owner);
|
|
963
1319
|
rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
|
|
964
1320
|
return Promise.resolve(false);
|
|
965
1321
|
}
|
|
966
|
-
log(`entry ${entryId} replay joined active delivery
|
|
1322
|
+
log(`entry ${entryId} replay joined active delivery`, owner);
|
|
967
1323
|
return active.promise;
|
|
968
1324
|
}
|
|
969
1325
|
|
|
@@ -972,6 +1328,7 @@ export function injectOpenCodeEntry(
|
|
|
972
1328
|
resolveDelivery = resolve;
|
|
973
1329
|
});
|
|
974
1330
|
const delivery: OpenCodeDelivery = {
|
|
1331
|
+
sequence: ++owner.nextObservationSequence,
|
|
975
1332
|
entryId,
|
|
976
1333
|
sourceEntryId,
|
|
977
1334
|
text,
|
|
@@ -1017,16 +1374,21 @@ export function settleOpenCodeEntry(sourceEntryId: string): void {
|
|
|
1017
1374
|
}
|
|
1018
1375
|
|
|
1019
1376
|
export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
|
|
1020
|
-
|
|
1377
|
+
const owner = state;
|
|
1378
|
+
if (!owner?.connected) return null;
|
|
1379
|
+
const observationSequence = ++owner.nextObservationSequence;
|
|
1021
1380
|
const binding = restoreBinding();
|
|
1022
1381
|
if (!binding) return false;
|
|
1023
1382
|
|
|
1024
1383
|
try {
|
|
1025
1384
|
const session = await getSession(binding.sessionId);
|
|
1385
|
+
if (state !== owner) return null;
|
|
1026
1386
|
if (session && isBoundSession(session, binding)) return true;
|
|
1387
|
+
recordOpenCodeFailure(owner, openCodeHttpError(404, 'session'), observationSequence);
|
|
1027
1388
|
clearBinding();
|
|
1028
1389
|
return false;
|
|
1029
|
-
} catch {
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
if (state === owner) recordOpenCodeFailure(owner, error, observationSequence);
|
|
1030
1392
|
return false;
|
|
1031
1393
|
}
|
|
1032
1394
|
}
|
|
@@ -1041,6 +1403,10 @@ export interface OpenCodeConnectionState {
|
|
|
1041
1403
|
sessionId: string | null;
|
|
1042
1404
|
totalEntriesInjected: number;
|
|
1043
1405
|
totalEntriesRetried: number;
|
|
1406
|
+
lastInjectionAt: number | null;
|
|
1407
|
+
lastInjectionResult: OpenCodeInjectionResult | null;
|
|
1408
|
+
lastAcceptedEntryId: string | null;
|
|
1409
|
+
lastFailureCode: string | null;
|
|
1044
1410
|
deliveryStates: Record<OpenCodeDeliveryState, number>;
|
|
1045
1411
|
}
|
|
1046
1412
|
|
|
@@ -1059,10 +1425,24 @@ export function getOpenCodeConnectionState(): OpenCodeConnectionState {
|
|
|
1059
1425
|
sessionId: state?.sessionId ?? null,
|
|
1060
1426
|
totalEntriesInjected: state?.totalEntriesInjected ?? 0,
|
|
1061
1427
|
totalEntriesRetried: state?.totalEntriesRetried ?? 0,
|
|
1428
|
+
lastInjectionAt: state?.lastObservation.lastInjectionAt ?? null,
|
|
1429
|
+
lastInjectionResult: state?.lastObservation.lastInjectionResult ?? null,
|
|
1430
|
+
lastAcceptedEntryId: state?.lastObservation.lastAcceptedEntryId ?? null,
|
|
1431
|
+
lastFailureCode: state?.lastObservation.lastFailureCode ?? null,
|
|
1062
1432
|
deliveryStates,
|
|
1063
1433
|
};
|
|
1064
1434
|
}
|
|
1065
1435
|
|
|
1436
|
+
export function __getOpenCodeDiagnosticLogPathForTests(): string {
|
|
1437
|
+
if (!state) throw new Error('OpenCode drone is not connected');
|
|
1438
|
+
return diagnosticLogPath(state);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
export function __getOpenCodeLastObservationForTests(): OpenCodeLastObservation {
|
|
1442
|
+
if (!state) throw new Error('OpenCode drone is not connected');
|
|
1443
|
+
return { ...state.lastObservation };
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1066
1446
|
export function computeOpenCodePort(droneId: string, base: number = 14096): number {
|
|
1067
1447
|
let hash = 0;
|
|
1068
1448
|
for (let i = 0; i < droneId.length; i++) {
|
|
@@ -1141,4 +1521,12 @@ export function __resetOpenCodeDroneForTests(): void {
|
|
|
1141
1521
|
}
|
|
1142
1522
|
}
|
|
1143
1523
|
bindingPathsForTests.clear();
|
|
1524
|
+
for (const path of diagnosticLogPathsForTests) {
|
|
1525
|
+
try {
|
|
1526
|
+
unlinkSync(path);
|
|
1527
|
+
} catch {
|
|
1528
|
+
// Already removed.
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
diagnosticLogPathsForTests.clear();
|
|
1144
1532
|
}
|