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.
Files changed (73) hide show
  1. package/dist/agent-integration-health.d.ts +2 -0
  2. package/dist/agent-integration-health.d.ts.map +1 -1
  3. package/dist/agent-integration-health.js +18 -1
  4. package/dist/agent-integration-health.js.map +1 -1
  5. package/dist/assimilate-cmd.d.ts +3 -0
  6. package/dist/assimilate-cmd.d.ts.map +1 -1
  7. package/dist/assimilate-cmd.js +12 -0
  8. package/dist/assimilate-cmd.js.map +1 -1
  9. package/dist/assimilate-deps.d.ts.map +1 -1
  10. package/dist/assimilate-deps.js +4 -2
  11. package/dist/assimilate-deps.js.map +1 -1
  12. package/dist/backends/launch-all-terminals.d.ts.map +1 -1
  13. package/dist/backends/launch-all-terminals.js +6 -3
  14. package/dist/backends/launch-all-terminals.js.map +1 -1
  15. package/dist/cli-help.d.ts.map +1 -1
  16. package/dist/cli-help.js +7 -4
  17. package/dist/cli-help.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +27 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/opencode-drone.d.ts +19 -0
  22. package/dist/opencode-drone.d.ts.map +1 -1
  23. package/dist/opencode-drone.js +374 -67
  24. package/dist/opencode-drone.js.map +1 -1
  25. package/dist/opencode-seat-identity.d.ts +1 -1
  26. package/dist/opencode-seat-identity.d.ts.map +1 -1
  27. package/dist/opencode-seat-identity.js.map +1 -1
  28. package/dist/private-root.d.ts +2 -0
  29. package/dist/private-root.d.ts.map +1 -1
  30. package/dist/private-root.js +35 -1
  31. package/dist/private-root.js.map +1 -1
  32. package/dist/roster-render.d.ts.map +1 -1
  33. package/dist/roster-render.js +2 -3
  34. package/dist/roster-render.js.map +1 -1
  35. package/dist/seats.d.ts +5 -0
  36. package/dist/seats.d.ts.map +1 -1
  37. package/dist/seats.js +10 -0
  38. package/dist/seats.js.map +1 -1
  39. package/dist/server-errors.d.ts +22 -0
  40. package/dist/server-errors.d.ts.map +1 -1
  41. package/dist/server-errors.js +32 -0
  42. package/dist/server-errors.js.map +1 -1
  43. package/dist/server-handshake.d.ts.map +1 -1
  44. package/dist/server-handshake.js +1 -0
  45. package/dist/server-handshake.js.map +1 -1
  46. package/dist/stream-status.d.ts.map +1 -1
  47. package/dist/stream-status.js +7 -2
  48. package/dist/stream-status.js.map +1 -1
  49. package/dist/terminal-title.d.ts.map +1 -1
  50. package/dist/terminal-title.js +3 -2
  51. package/dist/terminal-title.js.map +1 -1
  52. package/dist/update-cmd.d.ts +2 -1
  53. package/dist/update-cmd.d.ts.map +1 -1
  54. package/dist/update-cmd.js +110 -41
  55. package/dist/update-cmd.js.map +1 -1
  56. package/docs/LOCAL_SERVER.md +21 -10
  57. package/package.json +1 -1
  58. package/src/agent-integration-health.ts +20 -1
  59. package/src/assimilate-cmd.ts +17 -0
  60. package/src/assimilate-deps.ts +4 -1
  61. package/src/backends/launch-all-terminals.ts +6 -3
  62. package/src/cli-help.ts +7 -4
  63. package/src/index.ts +30 -3
  64. package/src/opencode-drone.ts +461 -73
  65. package/src/opencode-seat-identity.ts +1 -0
  66. package/src/private-root.ts +38 -1
  67. package/src/roster-render.ts +2 -3
  68. package/src/seats.ts +15 -0
  69. package/src/server-errors.ts +50 -0
  70. package/src/server-handshake.ts +1 -0
  71. package/src/stream-status.ts +7 -2
  72. package/src/terminal-title.ts +4 -2
  73. package/src/update-cmd.ts +118 -41
@@ -1,8 +1,26 @@
1
- import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
2
- import { createHash } from 'crypto';
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
- const LOG_FILE = join(tmpdir(), 'borg-opencode-drone.log');
19
- function log(msg: string) {
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
- try { appendFileSync(LOG_FILE, line); } catch {}
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?: string;
221
+ role: string;
69
222
  time?: { created?: number };
70
223
  };
71
- parts?: Array<{
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 Error('OpenCode API password is missing or unverifiable');
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 Error('OpenCode API password is missing or unverifiable');
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 new Error(`OpenCode sessions request failed (${status})`);
254
- return JSON.parse(body);
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 new Error(`OpenCode session request failed (${status})`);
261
- return JSON.parse(body);
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 new Error(`OpenCode session messages request failed (${status})`);
267
- return JSON.parse(body);
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 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`);
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 === state!.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
- 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
- }
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 sessions = (await listSessions()).filter(
505
- (session) => session.directory === state!.directory,
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(): Promise<OCSession | null> {
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
- const switched = await findUnseenTopLevelSession(binding.knownRootSessionIds);
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
- log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`);
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
- log(`entry ${delivery.entryId} target unavailable: ${err}`);
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
- log(`entry ${delivery.entryId} target unavailable: no bound session`);
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
- log(`entry ${delivery.entryId} confirmation unavailable: ${err}`);
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
- log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
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
- log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`);
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
- log(`entry ${delivery.entryId} delivery error: ${err}`);
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
- if (!state?.connected) { log('kickoff: not connected'); return false; }
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
- if (!state?.connected) return null;
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
  }