borgmcp 4.3.0 → 4.4.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.
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
1
+ import { appendFileSync, chmodSync, existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
2
2
  import { createHash } from 'crypto';
3
3
  import { createServer } from 'node:net';
4
4
  import { join } from 'path';
@@ -14,11 +14,51 @@ import {
14
14
  OPENCODE_SERVER_USERNAME,
15
15
  type OpenCodeLaunchTrust,
16
16
  } from './opencode-launch-trust.js';
17
+ import {
18
+ OpenCodeAuthenticationError,
19
+ OpenCodeHttpError,
20
+ OpenCodeResponseError,
21
+ OpenCodeUnreachableError,
22
+ type OpenCodeFailureCode,
23
+ } from './server-errors.js';
24
+
25
+ const OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES = 64 * 1024;
26
+ const diagnosticLogPathsForTests = new Set<string>();
27
+
28
+ function stateIdentityDigest(current: OpenCodeDroneState): string {
29
+ const key = [current.serverUrl, current.directory, current.cubeName, current.droneLabel].join('\0');
30
+ return createHash('sha256').update(key).digest('hex').slice(0, 24);
31
+ }
17
32
 
18
- const LOG_FILE = join(tmpdir(), 'borg-opencode-drone.log');
19
- function log(msg: string) {
33
+ function diagnosticLogPath(owner: OpenCodeDroneState): string {
34
+ const path = join(tmpdir(), `borg-opencode-drone-${stateIdentityDigest(owner)}.log`);
35
+ diagnosticLogPathsForTests.add(path);
36
+ return path;
37
+ }
38
+
39
+ function log(msg: string, owner: OpenCodeDroneState | null = state) {
20
40
  const line = `[${new Date().toISOString()}] ${msg}\n`;
21
- try { appendFileSync(LOG_FILE, line); } catch {}
41
+ if (!owner) {
42
+ process.stderr.write(line);
43
+ return;
44
+ }
45
+ try {
46
+ const path = diagnosticLogPath(owner);
47
+ if (existsSync(path)) chmodSync(path, 0o600);
48
+ appendFileSync(path, line, { encoding: 'utf8', mode: 0o600 });
49
+ chmodSync(path, 0o600);
50
+ if (statSync(path).size <= OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES) return;
51
+ const contents = readFileSync(path);
52
+ const tail = contents.subarray(contents.length - OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES);
53
+ const firstNewline = tail.indexOf(0x0a);
54
+ const bounded = firstNewline >= 0 ? tail.subarray(firstNewline + 1) : tail;
55
+ const temporary = `${path}.${process.pid}.tmp`;
56
+ writeFileSync(temporary, bounded, { mode: 0o600 });
57
+ renameSync(temporary, path);
58
+ } catch (error) {
59
+ const code = (error as NodeJS.ErrnoException | null)?.code ?? 'unknown';
60
+ process.stderr.write(`OpenCode diagnostic log write failed (${code})\n`);
61
+ }
22
62
  }
23
63
 
24
64
  interface OpenCodeDroneState {
@@ -41,8 +81,25 @@ interface OpenCodeDroneState {
41
81
  pendingSubmissions: Map<string, PendingOpenCodeSubmission>;
42
82
  reconcilingEntryIds: Set<string>;
43
83
  processingDeliveries: boolean;
84
+ nextObservationSequence: number;
85
+ lastObservation: OpenCodeLastObservation;
86
+ }
87
+
88
+ interface OpenCodeLastObservation {
89
+ injectionSequence: number;
90
+ acceptedSequence: number;
91
+ failureSequence: number;
92
+ lastInjectionAt: number | null;
93
+ lastInjectionResult: OpenCodeInjectionResult | null;
94
+ lastAcceptedEntryId: string | null;
95
+ lastFailureCode: string | null;
44
96
  }
45
97
 
98
+ type OpenCodeLastFields = Pick<
99
+ OpenCodeLastObservation,
100
+ 'lastInjectionAt' | 'lastInjectionResult' | 'lastAcceptedEntryId' | 'lastFailureCode'
101
+ >;
102
+
46
103
  let state: OpenCodeDroneState | null = null;
47
104
 
48
105
  interface ConnectDeps {
@@ -63,12 +120,12 @@ interface OCSession {
63
120
  }
64
121
 
65
122
  interface OCMessage {
66
- info?: {
123
+ info: {
67
124
  id?: string;
68
- role?: string;
125
+ role: string;
69
126
  time?: { created?: number };
70
127
  };
71
- parts?: Array<{
128
+ parts: Array<{
72
129
  type?: string;
73
130
  text?: string;
74
131
  metadata?: Record<string, unknown>;
@@ -82,6 +139,7 @@ export type OpenCodeDeliveryState =
82
139
  | 'failed';
83
140
 
84
141
  interface OpenCodeDelivery {
142
+ sequence: number;
85
143
  entryId: string;
86
144
  sourceEntryId: string;
87
145
  text: string;
@@ -107,6 +165,7 @@ interface PendingOpenCodeSubmission {
107
165
  }
108
166
 
109
167
  type OpenCodeDeliveryOutcome = 'delivered' | 'delivered-unconfirmed' | 'failed';
168
+ type OpenCodeInjectionResult = OpenCodeDeliveryOutcome;
110
169
 
111
170
  const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const;
112
171
  const OPEN_CODE_RECONCILIATION_DELAY_MS = 3_000;
@@ -168,7 +227,7 @@ function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
168
227
 
169
228
  export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
170
229
  if (!isOpenCode256BitIdentity(deps.apiPassword)) {
171
- throw new Error('OpenCode API password is missing or unverifiable');
230
+ throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
172
231
  }
173
232
  abandonOpenCodeDeliveries(state);
174
233
  state = {
@@ -191,8 +250,18 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
191
250
  pendingSubmissions: new Map(),
192
251
  reconcilingEntryIds: new Set(),
193
252
  processingDeliveries: false,
253
+ nextObservationSequence: 0,
254
+ lastObservation: {
255
+ injectionSequence: 0,
256
+ acceptedSequence: 0,
257
+ failureSequence: 0,
258
+ lastInjectionAt: null,
259
+ lastInjectionResult: null,
260
+ lastAcceptedEntryId: null,
261
+ lastFailureCode: null,
262
+ },
194
263
  };
195
- log(`connected url=${deps.serverUrl} dir=${deps.directory}`);
264
+ log(`connected url=${deps.serverUrl} dir=${deps.directory}`, state);
196
265
  }
197
266
 
198
267
  // ---------------------------------------------------------------------------
@@ -207,7 +276,7 @@ function apiUrl(path: string): string {
207
276
  function authenticatedHeaders(headers: Record<string, string> = {}): Record<string, string> {
208
277
  const password = state?.apiPassword;
209
278
  if (!isOpenCode256BitIdentity(password)) {
210
- throw new Error('OpenCode API password is missing or unverifiable');
279
+ throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
211
280
  }
212
281
  return {
213
282
  ...headers,
@@ -225,6 +294,13 @@ async function rawGet(path: string): Promise<{ status: number; body: string }> {
225
294
  const res = await fetch(url, { headers: authenticatedHeaders(), signal: controller.signal });
226
295
  const body = await res.text();
227
296
  return { status: res.status, body };
297
+ } catch (error) {
298
+ if (error instanceof OpenCodeAuthenticationError) throw error;
299
+ throw new OpenCodeUnreachableError(
300
+ controller.signal.aborted ? 'timeout' : 'transient',
301
+ controller.signal.aborted ? 'OpenCode request timed out' : 'OpenCode request failed',
302
+ { cause: error },
303
+ );
228
304
  } finally {
229
305
  clearTimeout(timer);
230
306
  }
@@ -243,28 +319,111 @@ async function rawPost(path: string, bodyObj: unknown): Promise<{ status: number
243
319
  });
244
320
  const body = await res.text();
245
321
  return { status: res.status, body };
322
+ } catch (error) {
323
+ if (error instanceof OpenCodeAuthenticationError) throw error;
324
+ throw new OpenCodeUnreachableError(
325
+ controller.signal.aborted ? 'timeout' : 'transient',
326
+ controller.signal.aborted ? 'OpenCode request timed out' : 'OpenCode request failed',
327
+ { cause: error },
328
+ );
246
329
  } finally {
247
330
  clearTimeout(timer);
248
331
  }
249
332
  }
250
333
 
334
+ function openCodeHttpError(status: number, operation: string): OpenCodeHttpError {
335
+ const code: OpenCodeFailureCode = status === 401
336
+ ? 'unauthorized'
337
+ : status === 404
338
+ ? 'not-found'
339
+ : status >= 500 || status === 429
340
+ ? 'transient'
341
+ : 'incompatible-api';
342
+ return new OpenCodeHttpError(status, code, `OpenCode ${operation} request failed (${status})`);
343
+ }
344
+
345
+ function parseOpenCodeJson(body: string): unknown {
346
+ try {
347
+ return JSON.parse(body);
348
+ } catch (error) {
349
+ throw new OpenCodeResponseError('OpenCode returned malformed JSON', { cause: error });
350
+ }
351
+ }
352
+
353
+ function isRecord(value: unknown): value is Record<string, unknown> {
354
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
355
+ }
356
+
357
+ function decodeSession(value: unknown): OCSession {
358
+ if (
359
+ !isRecord(value)
360
+ || typeof value.id !== 'string'
361
+ || value.id.length === 0
362
+ || typeof value.directory !== 'string'
363
+ || !isRecord(value.time)
364
+ || typeof value.time.created !== 'number'
365
+ || !Number.isFinite(value.time.created)
366
+ || (value.parentID !== undefined && typeof value.parentID !== 'string')
367
+ || (value.agent !== undefined && typeof value.agent !== 'string')
368
+ || (value.model !== undefined && (
369
+ !isRecord(value.model)
370
+ || typeof value.model.providerID !== 'string'
371
+ || typeof value.model.modelID !== 'string'
372
+ ))
373
+ ) {
374
+ throw new OpenCodeResponseError();
375
+ }
376
+ return value as unknown as OCSession;
377
+ }
378
+
379
+ function decodeSessions(body: string): OCSession[] {
380
+ const value = parseOpenCodeJson(body);
381
+ if (!Array.isArray(value)) throw new OpenCodeResponseError();
382
+ return value.map(decodeSession);
383
+ }
384
+
385
+ function decodeMessages(body: string): OCMessage[] {
386
+ const value = parseOpenCodeJson(body);
387
+ if (!Array.isArray(value)) throw new OpenCodeResponseError();
388
+ return value.map((message) => {
389
+ if (!isRecord(message)) throw new OpenCodeResponseError();
390
+ if (
391
+ !isRecord(message.info)
392
+ || typeof message.info.role !== 'string'
393
+ || (message.info.id !== undefined && typeof message.info.id !== 'string')
394
+ || (message.info.time !== undefined && (
395
+ !isRecord(message.info.time)
396
+ || (message.info.time.created !== undefined && typeof message.info.time.created !== 'number')
397
+ ))
398
+ ) throw new OpenCodeResponseError();
399
+ if (
400
+ !Array.isArray(message.parts)
401
+ || message.parts.some((part) => !isRecord(part)
402
+ || (part.type !== undefined && typeof part.type !== 'string')
403
+ || (part.text !== undefined && typeof part.text !== 'string')
404
+ || (part.metadata !== undefined && !isRecord(part.metadata)))
405
+ ) throw new OpenCodeResponseError();
406
+ return message as unknown as OCMessage;
407
+ });
408
+ }
409
+
251
410
  async function listSessions(): Promise<OCSession[]> {
252
411
  const { status, body } = await rawGet('/session');
253
- if (status !== 200) throw new Error(`OpenCode sessions request failed (${status})`);
254
- return JSON.parse(body);
412
+ if (status !== 200) throw openCodeHttpError(status, 'sessions');
413
+ return decodeSessions(body);
255
414
  }
256
415
 
257
416
  async function getSession(id: string): Promise<OCSession | null> {
258
417
  const { status, body } = await rawGet(`/session/${id}`);
259
418
  if (status === 404) return null;
260
- if (status !== 200) throw new Error(`OpenCode session request failed (${status})`);
261
- return JSON.parse(body);
419
+ if (status !== 200) throw openCodeHttpError(status, 'session');
420
+ return decodeSession(parseOpenCodeJson(body));
262
421
  }
263
422
 
264
423
  async function listSessionMessages(id: string): Promise<OCMessage[]> {
265
424
  const { status, body } = await rawGet(`/session/${id}/message`);
266
- if (status !== 200) throw new Error(`OpenCode session messages request failed (${status})`);
267
- return JSON.parse(body);
425
+ if (status !== 200) throw openCodeHttpError(status, 'session messages');
426
+ return decodeMessages(body);
268
427
  }
269
428
 
270
429
  async function findInjectedMessage(
@@ -298,9 +457,7 @@ async function promptSession(id: string, bodyObj: Record<string, unknown>): Prom
298
457
 
299
458
  function bindingPath(): string {
300
459
  const current = state!;
301
- const key = [current.serverUrl, current.directory, current.cubeName, current.droneLabel].join('\0');
302
- const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
303
- const path = join(tmpdir(), `borg-opencode-session-${digest}.json`);
460
+ const path = join(tmpdir(), `borg-opencode-session-${stateIdentityDigest(current)}.json`);
304
461
  bindingPathsForTests.add(path);
305
462
  return path;
306
463
  }
@@ -446,32 +603,28 @@ function restoreBinding(): SessionBinding | null {
446
603
  }
447
604
 
448
605
  function isBoundSession(session: OCSession, binding: SessionBinding): boolean {
449
- return session.id === binding.sessionId && session.directory === state!.directory;
606
+ return session.id === binding.sessionId && session.directory === binding.directory;
450
607
  }
451
608
 
452
609
  function isTopLevelSession(session: OCSession): boolean {
453
610
  return !session.parentID;
454
611
  }
455
612
 
456
- async function findUnseenTopLevelSession(knownRootSessionIds: string[]): Promise<{
613
+ async function findUnseenTopLevelSession(knownRootSessionIds: string[], directory: string): Promise<{
457
614
  session: OCSession;
458
615
  knownRootSessionIds: string[];
459
616
  } | null> {
460
- try {
461
- const sessions = await listSessions();
462
- const roots = sessions.filter(
463
- (session) => session.directory === state!.directory
464
- && isTopLevelSession(session),
465
- );
466
- const matched = roots.filter((session) => !knownRootSessionIds.includes(session.id));
467
- if (matched.length === 0) return null;
468
- const best = matched.reduce((a, b) =>
469
- a.time.created > b.time.created ? a : b,
470
- );
471
- return { session: best, knownRootSessionIds: roots.map((session) => session.id) };
472
- } catch {
473
- return null;
474
- }
617
+ const sessions = await listSessions();
618
+ const roots = sessions.filter(
619
+ (session) => session.directory === directory
620
+ && isTopLevelSession(session),
621
+ );
622
+ const matched = roots.filter((session) => !knownRootSessionIds.includes(session.id));
623
+ if (matched.length === 0) return null;
624
+ const best = matched.reduce((a, b) =>
625
+ a.time.created > b.time.created ? a : b,
626
+ );
627
+ return { session: best, knownRootSessionIds: roots.map((session) => session.id) };
475
628
  }
476
629
 
477
630
  function launchCorrelationMatchCount(messages: OCMessage[], correlationIdentity: string): number {
@@ -500,9 +653,13 @@ async function findLaunchSession(correlationIdentity: string): Promise<{
500
653
  session: OCSession;
501
654
  knownRootSessionIds: string[];
502
655
  } | null> {
656
+ const owner = state!;
657
+ const observationSequence = ++owner.nextObservationSequence;
503
658
  try {
504
- const sessions = (await listSessions()).filter(
505
- (session) => session.directory === state!.directory,
659
+ const listedSessions = await listSessions();
660
+ if (state !== owner) return null;
661
+ const sessions = listedSessions.filter(
662
+ (session) => session.directory === owner.directory,
506
663
  );
507
664
  const knownRootSessionIds = sessions
508
665
  .filter(isTopLevelSession)
@@ -514,23 +671,31 @@ async function findLaunchSession(correlationIdentity: string): Promise<{
514
671
  correlationIdentity,
515
672
  ),
516
673
  })));
674
+ if (state !== owner) return null;
517
675
  const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
518
676
  if (totalMatches !== 1) return null;
519
677
  const matched = candidates.find((candidate) => candidate.matchCount === 1);
520
678
  return matched ? { session: matched.session, knownRootSessionIds } : null;
521
- } catch {
679
+ } catch (error) {
680
+ if (state === owner) recordOpenCodeFailure(owner, error, observationSequence);
522
681
  return null;
523
682
  }
524
683
  }
525
684
 
526
- async function resolveInjectionSession(): Promise<OCSession | null> {
685
+ async function resolveInjectionSession(
686
+ owner: OpenCodeDroneState,
687
+ observationSequence: number,
688
+ ): Promise<OCSession | null> {
689
+ if (state !== owner) return null;
527
690
  const binding = restoreBinding();
528
691
  if (!binding) return null;
529
692
 
530
693
  const bound = await getSession(binding.sessionId);
694
+ if (state !== owner) return null;
531
695
  if (!bound || !isBoundSession(bound, binding)) {
532
696
  clearBinding();
533
- const replacement = await findUnseenTopLevelSession(binding.knownRootSessionIds);
697
+ const replacement = await findUnseenTopLevelSession(binding.knownRootSessionIds, owner.directory);
698
+ if (state !== owner) return null;
534
699
  if (!replacement) return null;
535
700
  saveBinding(replacement.session, replacement.knownRootSessionIds);
536
701
  return replacement.session;
@@ -539,7 +704,13 @@ async function resolveInjectionSession(): Promise<OCSession | null> {
539
704
  // `/new` creates an unseen top-level session. Keep the launch-time root
540
705
  // snapshot so an old, unrelated root is never mistaken for a user switch.
541
706
  // Children never supersede the bound root.
542
- const switched = await findUnseenTopLevelSession(binding.knownRootSessionIds);
707
+ let switched: Awaited<ReturnType<typeof findUnseenTopLevelSession>> = null;
708
+ try {
709
+ switched = await findUnseenTopLevelSession(binding.knownRootSessionIds, owner.directory);
710
+ } catch (error) {
711
+ recordOpenCodeFailure(owner, error, observationSequence);
712
+ }
713
+ if (state !== owner) return null;
543
714
  if (switched) {
544
715
  saveBinding(switched.session, switched.knownRootSessionIds);
545
716
  return switched.session;
@@ -562,6 +733,64 @@ function rememberBounded(
562
733
  }
563
734
  }
564
735
 
736
+ function openCodeFailureCode(error: unknown): string {
737
+ const code = (error as { code?: unknown } | null)?.code;
738
+ if (typeof code === 'string' && code.length > 0) return code;
739
+ return error instanceof Error && error.name ? error.name : 'unknown';
740
+ }
741
+
742
+ function updateLastOpenCodeObservation(
743
+ owner: OpenCodeDroneState,
744
+ sequence: number,
745
+ update: Partial<OpenCodeLastFields>,
746
+ ): void {
747
+ // Attempts, acceptances, and failures resolve independently; an observation
748
+ // may be stale for one field without being stale for the others.
749
+ const current = owner.lastObservation;
750
+ const updatesInjection = 'lastInjectionAt' in update || 'lastInjectionResult' in update;
751
+ const updatesAccepted = 'lastAcceptedEntryId' in update;
752
+ const updatesFailure = 'lastFailureCode' in update;
753
+ owner.lastObservation = {
754
+ ...current,
755
+ ...(updatesInjection && sequence >= current.injectionSequence
756
+ ? {
757
+ injectionSequence: sequence,
758
+ ...('lastInjectionAt' in update
759
+ ? { lastInjectionAt: update.lastInjectionAt as number | null }
760
+ : {}),
761
+ ...('lastInjectionResult' in update
762
+ ? { lastInjectionResult: update.lastInjectionResult as OpenCodeInjectionResult | null }
763
+ : {}),
764
+ }
765
+ : {}),
766
+ ...(updatesAccepted && sequence >= current.acceptedSequence
767
+ ? { acceptedSequence: sequence, lastAcceptedEntryId: update.lastAcceptedEntryId as string | null }
768
+ : {}),
769
+ ...(updatesFailure && sequence >= current.failureSequence
770
+ ? { failureSequence: sequence, lastFailureCode: update.lastFailureCode as string | null }
771
+ : {}),
772
+ };
773
+ }
774
+
775
+ function recordOpenCodeFailure(
776
+ owner: OpenCodeDroneState,
777
+ error: unknown,
778
+ observationSequence: number,
779
+ ): void {
780
+ updateLastOpenCodeObservation(owner, observationSequence, {
781
+ lastFailureCode: openCodeFailureCode(error),
782
+ });
783
+ }
784
+
785
+ function recordOpenCodeAcceptance(
786
+ owner: OpenCodeDroneState,
787
+ delivery: Pick<OpenCodeDelivery, 'sequence' | 'entryId'>,
788
+ ): void {
789
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
790
+ lastAcceptedEntryId: delivery.entryId,
791
+ });
792
+ }
793
+
565
794
  function clearPendingSubmission(owner: OpenCodeDroneState, entryId: string): void {
566
795
  if (!owner.pendingSubmissions.delete(entryId)) return;
567
796
  if (state === owner) persistCurrentBinding();
@@ -569,7 +798,7 @@ function clearPendingSubmission(owner: OpenCodeDroneState, entryId: string): voi
569
798
 
570
799
  function confirmOpenCodeDelivery(
571
800
  owner: OpenCodeDroneState,
572
- delivery: Pick<OpenCodeDelivery, 'entryId' | 'sourceEntryId' | 'text'>,
801
+ delivery: Pick<OpenCodeDelivery, 'sequence' | 'entryId' | 'sourceEntryId' | 'text'>,
573
802
  ): void {
574
803
  const unconfirmed = owner.unconfirmedEntries.get(delivery.entryId);
575
804
  if (unconfirmed && unconfirmed.text !== delivery.text) return;
@@ -583,6 +812,11 @@ function confirmOpenCodeDelivery(
583
812
  delivery.sourceEntryId,
584
813
  );
585
814
  owner.totalEntriesInjected++;
815
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
816
+ lastAcceptedEntryId: delivery.entryId,
817
+ lastInjectionResult: 'delivered',
818
+ lastFailureCode: null,
819
+ });
586
820
  }
587
821
 
588
822
  function scheduleOpenCodeReconciliation(
@@ -607,7 +841,8 @@ function scheduleOpenCodeReconciliation(
607
841
  return;
608
842
  }
609
843
  } catch (err) {
610
- log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`);
844
+ recordOpenCodeFailure(owner, err, delivery.sequence);
845
+ log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`, owner);
611
846
  }
612
847
  }
613
848
  } finally {
@@ -656,13 +891,15 @@ async function deliverOpenCodeEntry(
656
891
 
657
892
  if (!target) {
658
893
  try {
659
- target = await resolveInjectionSession();
894
+ target = await resolveInjectionSession(owner, delivery.sequence);
660
895
  } catch (err) {
661
- log(`entry ${delivery.entryId} target unavailable: ${err}`);
896
+ recordOpenCodeFailure(owner, err, delivery.sequence);
897
+ log(`entry ${delivery.entryId} target unavailable: ${err}`, owner);
662
898
  continue;
663
899
  }
664
900
  if (!target) {
665
- log(`entry ${delivery.entryId} target unavailable: no bound session`);
901
+ recordOpenCodeFailure(owner, openCodeHttpError(404, 'session'), delivery.sequence);
902
+ log(`entry ${delivery.entryId} target unavailable: no bound session`, owner);
666
903
  return 'failed';
667
904
  }
668
905
  delivery.sessionId = target.id;
@@ -679,12 +916,13 @@ async function deliverOpenCodeEntry(
679
916
  : await findInjectedMessage(confirmationSessionId, delivery.sourceEntryId)
680
917
  );
681
918
  if (deliveredIdentity) {
682
- log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}`);
919
+ log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}`, owner);
683
920
  clearPendingSubmission(owner, delivery.entryId);
684
921
  return 'delivered';
685
922
  }
686
923
  } catch (err) {
687
- log(`entry ${delivery.entryId} confirmation unavailable: ${err}`);
924
+ recordOpenCodeFailure(owner, err, delivery.sequence);
925
+ log(`entry ${delivery.entryId} confirmation unavailable: ${err}`, owner);
688
926
  continue;
689
927
  }
690
928
 
@@ -705,7 +943,7 @@ async function deliverOpenCodeEntry(
705
943
  });
706
944
  if (!persistCurrentBinding()) {
707
945
  owner.pendingSubmissions.delete(delivery.entryId);
708
- log(`entry ${delivery.entryId} submission skipped: pending intent was not durable`);
946
+ log(`entry ${delivery.entryId} submission skipped: pending intent was not durable`, owner);
709
947
  return 'failed';
710
948
  }
711
949
 
@@ -724,16 +962,19 @@ async function deliverOpenCodeEntry(
724
962
  }],
725
963
  });
726
964
  } catch (err) {
727
- log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
965
+ recordOpenCodeFailure(owner, err, delivery.sequence);
966
+ log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`, owner);
728
967
  }
729
968
 
730
969
  delivery.state = 'delivered-unconfirmed';
731
970
  if (status !== null && status !== 200 && status !== 204) {
971
+ recordOpenCodeFailure(owner, openCodeHttpError(status, 'prompt'), delivery.sequence);
732
972
  clearPendingSubmission(owner, delivery.entryId);
733
973
  if (status === 404) clearBinding();
734
974
  return 'failed';
735
975
  }
736
976
  delivery.acceptedSubmission = true;
977
+ if (status === 200 || status === 204) recordOpenCodeAcceptance(owner, delivery);
737
978
  }
738
979
 
739
980
  for (
@@ -755,7 +996,8 @@ async function deliverOpenCodeEntry(
755
996
  return 'delivered';
756
997
  }
757
998
  } catch (err) {
758
- log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`);
999
+ recordOpenCodeFailure(owner, err, delivery.sequence);
1000
+ log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`, owner);
759
1001
  }
760
1002
  }
761
1003
 
@@ -774,12 +1016,26 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
774
1016
  try {
775
1017
  while (state === owner && owner.deliveryQueue.length > 0) {
776
1018
  const delivery = owner.deliveryQueue.shift()!;
1019
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
1020
+ lastInjectionAt: Date.now(),
1021
+ lastInjectionResult: null,
1022
+ lastFailureCode: null,
1023
+ });
777
1024
  let outcome: OpenCodeDeliveryOutcome = 'failed';
778
1025
  try {
779
1026
  outcome = await deliverOpenCodeEntry(owner, delivery);
780
1027
  } catch (err) {
781
- log(`entry ${delivery.entryId} delivery error: ${err}`);
1028
+ recordOpenCodeFailure(owner, err, delivery.sequence);
1029
+ log(`entry ${delivery.entryId} delivery error: ${err}`, owner);
782
1030
  }
1031
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
1032
+ lastInjectionResult: outcome,
1033
+ lastFailureCode: outcome === 'delivered'
1034
+ ? null
1035
+ : outcome === 'failed'
1036
+ ? (owner.lastObservation.lastFailureCode ?? 'unknown')
1037
+ : owner.lastObservation.lastFailureCode,
1038
+ });
783
1039
 
784
1040
  owner.activeDeliveries.delete(delivery.entryId);
785
1041
  if (delivery.settled) {
@@ -823,9 +1079,10 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
823
1079
  * the separate MCP-child process, which must never fall back to a newest-session heuristic.
824
1080
  */
825
1081
  export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promise<boolean> {
826
- if (!state?.connected) { log('kickoff: not connected'); return false; }
1082
+ const owner = state;
1083
+ if (!owner?.connected) { log('kickoff: not connected', owner); return false; }
827
1084
  if (!isOpenCode256BitIdentity(launch.correlationIdentity)) {
828
- log('kickoff: correlation identity missing or unverifiable');
1085
+ log('kickoff: correlation identity missing or unverifiable', owner);
829
1086
  return false;
830
1087
  }
831
1088
 
@@ -834,7 +1091,7 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
834
1091
  for (let i = 0; i < 30; i++) {
835
1092
  try {
836
1093
  await listSessions();
837
- log(`kickoff: server ready (attempt ${i + 1})`);
1094
+ log(`kickoff: server ready (attempt ${i + 1})`, owner);
838
1095
  break;
839
1096
  } catch {
840
1097
  // not ready yet
@@ -847,17 +1104,18 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
847
1104
  for (let i = 0; i < 30; i++) {
848
1105
  const binding = await findLaunchSession(launch.correlationIdentity);
849
1106
  if (binding) {
1107
+ if (state !== owner) return false;
850
1108
  saveBinding(binding.session, binding.knownRootSessionIds);
851
- log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`);
1109
+ log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`, owner);
852
1110
  return true;
853
1111
  }
854
1112
  await new Promise((r) => setTimeout(r, 1000));
855
1113
  }
856
1114
 
857
- log('kickoff: no session found');
1115
+ log('kickoff: no session found', owner);
858
1116
  return false;
859
1117
  } catch (err) {
860
- log(`kickoff error: ${err}`);
1118
+ log(`kickoff error: ${err}`, owner);
861
1119
  return false;
862
1120
  }
863
1121
  }
@@ -879,7 +1137,7 @@ export function injectOpenCodeEntry(
879
1137
  ): Promise<boolean> {
880
1138
  const owner = state;
881
1139
  if (!owner?.connected) {
882
- log(`entry ${entryId} rejected: OpenCode is not connected`);
1140
+ log(`entry ${entryId} rejected: OpenCode is not connected`, owner);
883
1141
  return Promise.resolve(false);
884
1142
  }
885
1143
 
@@ -893,27 +1151,27 @@ export function injectOpenCodeEntry(
893
1151
  pendingEntryId !== entryId && pending.sourceEntryId === sourceEntryId,
894
1152
  );
895
1153
  if (pendingSource) {
896
- log(`entry ${entryId} reconciles pending source ${sourceEntryId}`);
1154
+ log(`entry ${entryId} reconciles pending source ${sourceEntryId}`, owner);
897
1155
  return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId, isSourcePending);
898
1156
  }
899
1157
  for (const [deliveredEntryId, record] of owner.deliveredEntries) {
900
1158
  if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
901
1159
  if (record.text !== text) return Promise.resolve(false);
902
- log(`entry ${entryId} source ${sourceEntryId} already delivered`);
1160
+ log(`entry ${entryId} source ${sourceEntryId} already delivered`, owner);
903
1161
  return Promise.resolve(true);
904
1162
  }
905
1163
  }
906
1164
  for (const [unconfirmedEntryId, record] of owner.unconfirmedEntries) {
907
1165
  if (unconfirmedEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
908
1166
  if (record.text !== text) return Promise.resolve(false);
909
- log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed`);
1167
+ log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed`, owner);
910
1168
  return Promise.resolve(true);
911
1169
  }
912
1170
  }
913
1171
  for (const active of owner.activeDeliveries.values()) {
914
1172
  if (active.entryId !== entryId && active.sourceEntryId === sourceEntryId) {
915
1173
  if (active.text !== text) return Promise.resolve(false);
916
- log(`entry ${entryId} joined active source ${sourceEntryId}`);
1174
+ log(`entry ${entryId} joined active source ${sourceEntryId}`, owner);
917
1175
  return active.promise;
918
1176
  }
919
1177
  }
@@ -921,26 +1179,27 @@ export function injectOpenCodeEntry(
921
1179
  const delivered = owner.deliveredEntries.get(entryId);
922
1180
  if (delivered !== undefined) {
923
1181
  if (delivered.text !== text || delivered.sourceEntryId !== sourceEntryId) {
924
- log(`entry ${entryId} replay text mismatch`);
1182
+ log(`entry ${entryId} replay text mismatch`, owner);
925
1183
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
926
1184
  return Promise.resolve(false);
927
1185
  }
928
- log(`entry ${entryId} replay already delivered`);
1186
+ log(`entry ${entryId} replay already delivered`, owner);
929
1187
  return Promise.resolve(true);
930
1188
  }
931
1189
 
932
1190
  const unconfirmed = owner.unconfirmedEntries.get(entryId);
933
1191
  if (unconfirmed !== undefined) {
934
1192
  if (unconfirmed.text !== text || unconfirmed.sourceEntryId !== sourceEntryId) {
935
- log(`entry ${entryId} unconfirmed replay text mismatch`);
1193
+ log(`entry ${entryId} unconfirmed replay text mismatch`, owner);
936
1194
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
937
1195
  return Promise.resolve(false);
938
1196
  }
939
- log(`entry ${entryId} replay remains unconfirmed`);
1197
+ log(`entry ${entryId} replay remains unconfirmed`, owner);
940
1198
  const pending = owner.pendingSubmissions.get(entryId);
941
1199
  const accepted = pending !== undefined;
942
1200
  if (pending) {
943
1201
  scheduleOpenCodeReconciliation(owner, {
1202
+ sequence: ++owner.nextObservationSequence,
944
1203
  entryId,
945
1204
  sourceEntryId,
946
1205
  text,
@@ -959,11 +1218,11 @@ export function injectOpenCodeEntry(
959
1218
  const active = owner.activeDeliveries.get(entryId);
960
1219
  if (active) {
961
1220
  if (active.text !== text || active.sourceEntryId !== sourceEntryId) {
962
- log(`entry ${entryId} active text mismatch`);
1221
+ log(`entry ${entryId} active text mismatch`, owner);
963
1222
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
964
1223
  return Promise.resolve(false);
965
1224
  }
966
- log(`entry ${entryId} replay joined active delivery`);
1225
+ log(`entry ${entryId} replay joined active delivery`, owner);
967
1226
  return active.promise;
968
1227
  }
969
1228
 
@@ -972,6 +1231,7 @@ export function injectOpenCodeEntry(
972
1231
  resolveDelivery = resolve;
973
1232
  });
974
1233
  const delivery: OpenCodeDelivery = {
1234
+ sequence: ++owner.nextObservationSequence,
975
1235
  entryId,
976
1236
  sourceEntryId,
977
1237
  text,
@@ -1017,16 +1277,21 @@ export function settleOpenCodeEntry(sourceEntryId: string): void {
1017
1277
  }
1018
1278
 
1019
1279
  export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
1020
- if (!state?.connected) return null;
1280
+ const owner = state;
1281
+ if (!owner?.connected) return null;
1282
+ const observationSequence = ++owner.nextObservationSequence;
1021
1283
  const binding = restoreBinding();
1022
1284
  if (!binding) return false;
1023
1285
 
1024
1286
  try {
1025
1287
  const session = await getSession(binding.sessionId);
1288
+ if (state !== owner) return null;
1026
1289
  if (session && isBoundSession(session, binding)) return true;
1290
+ recordOpenCodeFailure(owner, openCodeHttpError(404, 'session'), observationSequence);
1027
1291
  clearBinding();
1028
1292
  return false;
1029
- } catch {
1293
+ } catch (error) {
1294
+ if (state === owner) recordOpenCodeFailure(owner, error, observationSequence);
1030
1295
  return false;
1031
1296
  }
1032
1297
  }
@@ -1041,6 +1306,10 @@ export interface OpenCodeConnectionState {
1041
1306
  sessionId: string | null;
1042
1307
  totalEntriesInjected: number;
1043
1308
  totalEntriesRetried: number;
1309
+ lastInjectionAt: number | null;
1310
+ lastInjectionResult: OpenCodeInjectionResult | null;
1311
+ lastAcceptedEntryId: string | null;
1312
+ lastFailureCode: string | null;
1044
1313
  deliveryStates: Record<OpenCodeDeliveryState, number>;
1045
1314
  }
1046
1315
 
@@ -1059,10 +1328,24 @@ export function getOpenCodeConnectionState(): OpenCodeConnectionState {
1059
1328
  sessionId: state?.sessionId ?? null,
1060
1329
  totalEntriesInjected: state?.totalEntriesInjected ?? 0,
1061
1330
  totalEntriesRetried: state?.totalEntriesRetried ?? 0,
1331
+ lastInjectionAt: state?.lastObservation.lastInjectionAt ?? null,
1332
+ lastInjectionResult: state?.lastObservation.lastInjectionResult ?? null,
1333
+ lastAcceptedEntryId: state?.lastObservation.lastAcceptedEntryId ?? null,
1334
+ lastFailureCode: state?.lastObservation.lastFailureCode ?? null,
1062
1335
  deliveryStates,
1063
1336
  };
1064
1337
  }
1065
1338
 
1339
+ export function __getOpenCodeDiagnosticLogPathForTests(): string {
1340
+ if (!state) throw new Error('OpenCode drone is not connected');
1341
+ return diagnosticLogPath(state);
1342
+ }
1343
+
1344
+ export function __getOpenCodeLastObservationForTests(): OpenCodeLastObservation {
1345
+ if (!state) throw new Error('OpenCode drone is not connected');
1346
+ return { ...state.lastObservation };
1347
+ }
1348
+
1066
1349
  export function computeOpenCodePort(droneId: string, base: number = 14096): number {
1067
1350
  let hash = 0;
1068
1351
  for (let i = 0; i < droneId.length; i++) {
@@ -1141,4 +1424,12 @@ export function __resetOpenCodeDroneForTests(): void {
1141
1424
  }
1142
1425
  }
1143
1426
  bindingPathsForTests.clear();
1427
+ for (const path of diagnosticLogPathsForTests) {
1428
+ try {
1429
+ unlinkSync(path);
1430
+ } catch {
1431
+ // Already removed.
1432
+ }
1433
+ }
1434
+ diagnosticLogPathsForTests.clear();
1144
1435
  }