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,17 +1,114 @@
1
- import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
2
- import { createHash } from 'crypto';
1
+ import { closeSync, constants, existsSync, fchmodSync, fstatSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync, writeSync, } from 'fs';
2
+ import { createHash, randomUUID } from 'crypto';
3
3
  import { createServer } from 'node:net';
4
4
  import { join } from 'path';
5
5
  import { tmpdir } from 'os';
6
+ import { borgConfigRoot, ensurePrivateBorgConfigRoot, ensurePrivateBorgConfigRootSync, } from './private-root.js';
6
7
  import { OPENCODE_INJECTED_ENTRY_METADATA_KEY, OPENCODE_WAKE_IDENTITY_METADATA_KEY, OPENCODE_LAUNCH_CORRELATION_METADATA_KEY, } from './opencode-plugin.js';
7
8
  import { createOpenCodeLaunchTrust, isOpenCode256BitIdentity, OPENCODE_SERVER_USERNAME, } from './opencode-launch-trust.js';
8
- const LOG_FILE = join(tmpdir(), 'borg-opencode-drone.log');
9
- function log(msg) {
9
+ import { OpenCodeAuthenticationError, OpenCodeHttpError, OpenCodeResponseError, OpenCodeUnreachableError, } from './server-errors.js';
10
+ const OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES = 64 * 1024;
11
+ const diagnosticLogPathsForTests = new Set();
12
+ function stateIdentityDigest(current) {
13
+ const key = [current.serverUrl, current.directory, current.cubeName, current.droneLabel].join('\0');
14
+ return createHash('sha256').update(key).digest('hex').slice(0, 24);
15
+ }
16
+ export function openCodeStartupDiagnosticLogPath() {
17
+ return join(borgConfigRoot(), 'opencode-drone-startup.log');
18
+ }
19
+ function diagnosticLogPath(owner) {
20
+ const root = borgConfigRoot();
21
+ const path = owner
22
+ ? join(root, `opencode-drone-${stateIdentityDigest(owner)}.log`)
23
+ : openCodeStartupDiagnosticLogPath();
24
+ diagnosticLogPathsForTests.add(path);
25
+ return path;
26
+ }
27
+ function log(msg, owner = state, throwOnFailure = false) {
10
28
  const line = `[${new Date().toISOString()}] ${msg}\n`;
29
+ let descriptor = null;
30
+ let temporaryDescriptor = null;
31
+ let temporary = null;
11
32
  try {
12
- appendFileSync(LOG_FILE, line);
33
+ ensurePrivateBorgConfigRootSync(borgConfigRoot());
34
+ const path = diagnosticLogPath(owner);
35
+ // The private root is the primary boundary. Where available, no-follow also
36
+ // closes the final replacement gap between root verification and this open.
37
+ const noFollow = constants.O_NOFOLLOW ?? 0;
38
+ descriptor = openSync(path, constants.O_RDWR |
39
+ constants.O_APPEND |
40
+ constants.O_CREAT |
41
+ noFollow, 0o600);
42
+ if (!fstatSync(descriptor).isFile()) {
43
+ throw Object.assign(new Error('OpenCode diagnostic log is not a regular file'), { code: 'EINVAL' });
44
+ }
45
+ fchmodSync(descriptor, 0o600);
46
+ writeSync(descriptor, line, null, 'utf8');
47
+ const size = fstatSync(descriptor).size;
48
+ if (size <= OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES)
49
+ return;
50
+ const tail = Buffer.allocUnsafe(OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES);
51
+ let bytesRead = 0;
52
+ while (bytesRead < tail.length) {
53
+ const count = readSync(descriptor, tail, bytesRead, tail.length - bytesRead, size - tail.length + bytesRead);
54
+ if (count === 0)
55
+ break;
56
+ bytesRead += count;
57
+ }
58
+ const completeTail = tail.subarray(0, bytesRead);
59
+ const firstNewline = completeTail.indexOf(0x0a);
60
+ const bounded = firstNewline >= 0 ? completeTail.subarray(firstNewline + 1) : completeTail;
61
+ temporary = `${path}.${randomUUID()}.tmp`;
62
+ temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
63
+ if (!fstatSync(temporaryDescriptor).isFile()) {
64
+ throw Object.assign(new Error('OpenCode diagnostic temporary is not a regular file'), { code: 'EINVAL' });
65
+ }
66
+ fchmodSync(temporaryDescriptor, 0o600);
67
+ let bytesWritten = 0;
68
+ while (bytesWritten < bounded.length) {
69
+ const count = writeSync(temporaryDescriptor, bounded, bytesWritten, bounded.length - bytesWritten);
70
+ if (count === 0)
71
+ throw Object.assign(new Error('OpenCode diagnostic temporary write stalled'), { code: 'EIO' });
72
+ bytesWritten += count;
73
+ }
74
+ closeSync(temporaryDescriptor);
75
+ temporaryDescriptor = null;
76
+ closeSync(descriptor);
77
+ descriptor = null;
78
+ renameSync(temporary, path);
79
+ temporary = null;
80
+ }
81
+ catch (error) {
82
+ const code = error?.code ?? 'unknown';
83
+ process.stderr.write(`OpenCode diagnostic log write failed (${code})\n`);
84
+ if (throwOnFailure)
85
+ throw error;
86
+ }
87
+ finally {
88
+ if (temporaryDescriptor !== null) {
89
+ try {
90
+ closeSync(temporaryDescriptor);
91
+ }
92
+ catch { /* The primary write error is already reported. */ }
93
+ }
94
+ if (descriptor !== null) {
95
+ try {
96
+ closeSync(descriptor);
97
+ }
98
+ catch { /* The primary write error is already reported. */ }
99
+ }
100
+ if (temporary !== null) {
101
+ try {
102
+ unlinkSync(temporary);
103
+ }
104
+ catch {
105
+ // Already absent or inaccessible; the randomized name cannot be reused.
106
+ }
107
+ }
13
108
  }
14
- catch { }
109
+ }
110
+ export function writeOpenCodeStartupDiagnostic(message) {
111
+ log(message, null, true);
15
112
  }
16
113
  let state = null;
17
114
  const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000];
@@ -46,8 +143,9 @@ function abandonOpenCodeDeliveries(current) {
46
143
  }
47
144
  export async function connectOpenCodeDrone(deps) {
48
145
  if (!isOpenCode256BitIdentity(deps.apiPassword)) {
49
- throw new Error('OpenCode API password is missing or unverifiable');
146
+ throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
50
147
  }
148
+ await ensurePrivateBorgConfigRoot(borgConfigRoot());
51
149
  abandonOpenCodeDeliveries(state);
52
150
  state = {
53
151
  serverUrl: deps.serverUrl,
@@ -69,8 +167,18 @@ export async function connectOpenCodeDrone(deps) {
69
167
  pendingSubmissions: new Map(),
70
168
  reconcilingEntryIds: new Set(),
71
169
  processingDeliveries: false,
170
+ nextObservationSequence: 0,
171
+ lastObservation: {
172
+ injectionSequence: 0,
173
+ acceptedSequence: 0,
174
+ failureSequence: 0,
175
+ lastInjectionAt: null,
176
+ lastInjectionResult: null,
177
+ lastAcceptedEntryId: null,
178
+ lastFailureCode: null,
179
+ },
72
180
  };
73
- log(`connected url=${deps.serverUrl} dir=${deps.directory}`);
181
+ log(`connected url=${deps.serverUrl} dir=${deps.directory}`, state);
74
182
  }
75
183
  // ---------------------------------------------------------------------------
76
184
  // Raw fetch wrappers
@@ -82,7 +190,7 @@ function apiUrl(path) {
82
190
  function authenticatedHeaders(headers = {}) {
83
191
  const password = state?.apiPassword;
84
192
  if (!isOpenCode256BitIdentity(password)) {
85
- throw new Error('OpenCode API password is missing or unverifiable');
193
+ throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
86
194
  }
87
195
  return {
88
196
  ...headers,
@@ -99,6 +207,11 @@ async function rawGet(path) {
99
207
  const body = await res.text();
100
208
  return { status: res.status, body };
101
209
  }
210
+ catch (error) {
211
+ if (error instanceof OpenCodeAuthenticationError)
212
+ throw error;
213
+ throw new OpenCodeUnreachableError(controller.signal.aborted ? 'timeout' : 'transient', controller.signal.aborted ? 'OpenCode request timed out' : 'OpenCode request failed', { cause: error });
214
+ }
102
215
  finally {
103
216
  clearTimeout(timer);
104
217
  }
@@ -117,29 +230,100 @@ async function rawPost(path, bodyObj) {
117
230
  const body = await res.text();
118
231
  return { status: res.status, body };
119
232
  }
233
+ catch (error) {
234
+ if (error instanceof OpenCodeAuthenticationError)
235
+ throw error;
236
+ throw new OpenCodeUnreachableError(controller.signal.aborted ? 'timeout' : 'transient', controller.signal.aborted ? 'OpenCode request timed out' : 'OpenCode request failed', { cause: error });
237
+ }
120
238
  finally {
121
239
  clearTimeout(timer);
122
240
  }
123
241
  }
242
+ function openCodeHttpError(status, operation) {
243
+ const code = status === 401
244
+ ? 'unauthorized'
245
+ : status === 404
246
+ ? 'not-found'
247
+ : status >= 500 || status === 429
248
+ ? 'transient'
249
+ : 'incompatible-api';
250
+ return new OpenCodeHttpError(status, code, `OpenCode ${operation} request failed (${status})`);
251
+ }
252
+ function parseOpenCodeJson(body) {
253
+ try {
254
+ return JSON.parse(body);
255
+ }
256
+ catch (error) {
257
+ throw new OpenCodeResponseError('OpenCode returned malformed JSON', { cause: error });
258
+ }
259
+ }
260
+ function isRecord(value) {
261
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
262
+ }
263
+ function decodeSession(value) {
264
+ if (!isRecord(value)
265
+ || typeof value.id !== 'string'
266
+ || value.id.length === 0
267
+ || typeof value.directory !== 'string'
268
+ || !isRecord(value.time)
269
+ || typeof value.time.created !== 'number'
270
+ || !Number.isFinite(value.time.created)
271
+ || (value.parentID !== undefined && typeof value.parentID !== 'string')
272
+ || (value.agent !== undefined && typeof value.agent !== 'string')
273
+ || (value.model !== undefined && (!isRecord(value.model)
274
+ || typeof value.model.providerID !== 'string'
275
+ || typeof value.model.modelID !== 'string'))) {
276
+ throw new OpenCodeResponseError();
277
+ }
278
+ return value;
279
+ }
280
+ function decodeSessions(body) {
281
+ const value = parseOpenCodeJson(body);
282
+ if (!Array.isArray(value))
283
+ throw new OpenCodeResponseError();
284
+ return value.map(decodeSession);
285
+ }
286
+ function decodeMessages(body) {
287
+ const value = parseOpenCodeJson(body);
288
+ if (!Array.isArray(value))
289
+ throw new OpenCodeResponseError();
290
+ return value.map((message) => {
291
+ if (!isRecord(message))
292
+ throw new OpenCodeResponseError();
293
+ if (!isRecord(message.info)
294
+ || typeof message.info.role !== 'string'
295
+ || (message.info.id !== undefined && typeof message.info.id !== 'string')
296
+ || (message.info.time !== undefined && (!isRecord(message.info.time)
297
+ || (message.info.time.created !== undefined && typeof message.info.time.created !== 'number'))))
298
+ throw new OpenCodeResponseError();
299
+ if (!Array.isArray(message.parts)
300
+ || message.parts.some((part) => !isRecord(part)
301
+ || (part.type !== undefined && typeof part.type !== 'string')
302
+ || (part.text !== undefined && typeof part.text !== 'string')
303
+ || (part.metadata !== undefined && !isRecord(part.metadata))))
304
+ throw new OpenCodeResponseError();
305
+ return message;
306
+ });
307
+ }
124
308
  async function listSessions() {
125
309
  const { status, body } = await rawGet('/session');
126
310
  if (status !== 200)
127
- throw new Error(`OpenCode sessions request failed (${status})`);
128
- return JSON.parse(body);
311
+ throw openCodeHttpError(status, 'sessions');
312
+ return decodeSessions(body);
129
313
  }
130
314
  async function getSession(id) {
131
315
  const { status, body } = await rawGet(`/session/${id}`);
132
316
  if (status === 404)
133
317
  return null;
134
318
  if (status !== 200)
135
- throw new Error(`OpenCode session request failed (${status})`);
136
- return JSON.parse(body);
319
+ throw openCodeHttpError(status, 'session');
320
+ return decodeSession(parseOpenCodeJson(body));
137
321
  }
138
322
  async function listSessionMessages(id) {
139
323
  const { status, body } = await rawGet(`/session/${id}/message`);
140
324
  if (status !== 200)
141
- throw new Error(`OpenCode session messages request failed (${status})`);
142
- return JSON.parse(body);
325
+ throw openCodeHttpError(status, 'session messages');
326
+ return decodeMessages(body);
143
327
  }
144
328
  async function findInjectedMessage(sessionId, expectedWakeIdentity) {
145
329
  const messages = await listSessionMessages(sessionId);
@@ -165,9 +349,7 @@ async function promptSession(id, bodyObj) {
165
349
  // ---------------------------------------------------------------------------
166
350
  function bindingPath() {
167
351
  const current = state;
168
- const key = [current.serverUrl, current.directory, current.cubeName, current.droneLabel].join('\0');
169
- const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
170
- const path = join(tmpdir(), `borg-opencode-session-${digest}.json`);
352
+ const path = join(tmpdir(), `borg-opencode-session-${stateIdentityDigest(current)}.json`);
171
353
  bindingPathsForTests.add(path);
172
354
  return path;
173
355
  }
@@ -310,25 +492,20 @@ function restoreBinding() {
310
492
  return binding;
311
493
  }
312
494
  function isBoundSession(session, binding) {
313
- return session.id === binding.sessionId && session.directory === state.directory;
495
+ return session.id === binding.sessionId && session.directory === binding.directory;
314
496
  }
315
497
  function isTopLevelSession(session) {
316
498
  return !session.parentID;
317
499
  }
318
- async function findUnseenTopLevelSession(knownRootSessionIds) {
319
- try {
320
- const sessions = await listSessions();
321
- const roots = sessions.filter((session) => session.directory === state.directory
322
- && isTopLevelSession(session));
323
- const matched = roots.filter((session) => !knownRootSessionIds.includes(session.id));
324
- if (matched.length === 0)
325
- return null;
326
- const best = matched.reduce((a, b) => a.time.created > b.time.created ? a : b);
327
- return { session: best, knownRootSessionIds: roots.map((session) => session.id) };
328
- }
329
- catch {
500
+ async function findUnseenTopLevelSession(knownRootSessionIds, directory) {
501
+ const sessions = await listSessions();
502
+ const roots = sessions.filter((session) => session.directory === directory
503
+ && isTopLevelSession(session));
504
+ const matched = roots.filter((session) => !knownRootSessionIds.includes(session.id));
505
+ if (matched.length === 0)
330
506
  return null;
331
- }
507
+ const best = matched.reduce((a, b) => a.time.created > b.time.created ? a : b);
508
+ return { session: best, knownRootSessionIds: roots.map((session) => session.id) };
332
509
  }
333
510
  function launchCorrelationMatchCount(messages, correlationIdentity) {
334
511
  let count = 0;
@@ -351,8 +528,13 @@ function launchCorrelationMatchCount(messages, correlationIdentity) {
351
528
  * therefore allowed only when it received this launch's correlation metadata.
352
529
  */
353
530
  async function findLaunchSession(correlationIdentity) {
531
+ const owner = state;
532
+ const observationSequence = ++owner.nextObservationSequence;
354
533
  try {
355
- const sessions = (await listSessions()).filter((session) => session.directory === state.directory);
534
+ const listedSessions = await listSessions();
535
+ if (state !== owner)
536
+ return null;
537
+ const sessions = listedSessions.filter((session) => session.directory === owner.directory);
356
538
  const knownRootSessionIds = sessions
357
539
  .filter(isTopLevelSession)
358
540
  .map((session) => session.id);
@@ -360,24 +542,34 @@ async function findLaunchSession(correlationIdentity) {
360
542
  session,
361
543
  matchCount: launchCorrelationMatchCount(await listSessionMessages(session.id), correlationIdentity),
362
544
  })));
545
+ if (state !== owner)
546
+ return null;
363
547
  const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
364
548
  if (totalMatches !== 1)
365
549
  return null;
366
550
  const matched = candidates.find((candidate) => candidate.matchCount === 1);
367
551
  return matched ? { session: matched.session, knownRootSessionIds } : null;
368
552
  }
369
- catch {
553
+ catch (error) {
554
+ if (state === owner)
555
+ recordOpenCodeFailure(owner, error, observationSequence);
370
556
  return null;
371
557
  }
372
558
  }
373
- async function resolveInjectionSession() {
559
+ async function resolveInjectionSession(owner, observationSequence) {
560
+ if (state !== owner)
561
+ return null;
374
562
  const binding = restoreBinding();
375
563
  if (!binding)
376
564
  return null;
377
565
  const bound = await getSession(binding.sessionId);
566
+ if (state !== owner)
567
+ return null;
378
568
  if (!bound || !isBoundSession(bound, binding)) {
379
569
  clearBinding();
380
- const replacement = await findUnseenTopLevelSession(binding.knownRootSessionIds);
570
+ const replacement = await findUnseenTopLevelSession(binding.knownRootSessionIds, owner.directory);
571
+ if (state !== owner)
572
+ return null;
381
573
  if (!replacement)
382
574
  return null;
383
575
  saveBinding(replacement.session, replacement.knownRootSessionIds);
@@ -386,7 +578,15 @@ async function resolveInjectionSession() {
386
578
  // `/new` creates an unseen top-level session. Keep the launch-time root
387
579
  // snapshot so an old, unrelated root is never mistaken for a user switch.
388
580
  // Children never supersede the bound root.
389
- const switched = await findUnseenTopLevelSession(binding.knownRootSessionIds);
581
+ let switched = null;
582
+ try {
583
+ switched = await findUnseenTopLevelSession(binding.knownRootSessionIds, owner.directory);
584
+ }
585
+ catch (error) {
586
+ recordOpenCodeFailure(owner, error, observationSequence);
587
+ }
588
+ if (state !== owner)
589
+ return null;
390
590
  if (switched) {
391
591
  saveBinding(switched.session, switched.knownRootSessionIds);
392
592
  return switched.session;
@@ -403,6 +603,50 @@ function rememberBounded(entries, entryId, text, sourceEntryId) {
403
603
  entries.delete(oldest);
404
604
  }
405
605
  }
606
+ function openCodeFailureCode(error) {
607
+ const code = error?.code;
608
+ if (typeof code === 'string' && code.length > 0)
609
+ return code;
610
+ return error instanceof Error && error.name ? error.name : 'unknown';
611
+ }
612
+ function updateLastOpenCodeObservation(owner, sequence, update) {
613
+ // Attempts, acceptances, and failures resolve independently; an observation
614
+ // may be stale for one field without being stale for the others.
615
+ const current = owner.lastObservation;
616
+ const updatesInjection = 'lastInjectionAt' in update || 'lastInjectionResult' in update;
617
+ const updatesAccepted = 'lastAcceptedEntryId' in update;
618
+ const updatesFailure = 'lastFailureCode' in update;
619
+ owner.lastObservation = {
620
+ ...current,
621
+ ...(updatesInjection && sequence >= current.injectionSequence
622
+ ? {
623
+ injectionSequence: sequence,
624
+ ...('lastInjectionAt' in update
625
+ ? { lastInjectionAt: update.lastInjectionAt }
626
+ : {}),
627
+ ...('lastInjectionResult' in update
628
+ ? { lastInjectionResult: update.lastInjectionResult }
629
+ : {}),
630
+ }
631
+ : {}),
632
+ ...(updatesAccepted && sequence >= current.acceptedSequence
633
+ ? { acceptedSequence: sequence, lastAcceptedEntryId: update.lastAcceptedEntryId }
634
+ : {}),
635
+ ...(updatesFailure && sequence >= current.failureSequence
636
+ ? { failureSequence: sequence, lastFailureCode: update.lastFailureCode }
637
+ : {}),
638
+ };
639
+ }
640
+ function recordOpenCodeFailure(owner, error, observationSequence) {
641
+ updateLastOpenCodeObservation(owner, observationSequence, {
642
+ lastFailureCode: openCodeFailureCode(error),
643
+ });
644
+ }
645
+ function recordOpenCodeAcceptance(owner, delivery) {
646
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
647
+ lastAcceptedEntryId: delivery.entryId,
648
+ });
649
+ }
406
650
  function clearPendingSubmission(owner, entryId) {
407
651
  if (!owner.pendingSubmissions.delete(entryId))
408
652
  return;
@@ -418,6 +662,11 @@ function confirmOpenCodeDelivery(owner, delivery) {
418
662
  clearPendingSubmission(owner, delivery.entryId);
419
663
  rememberBounded(owner.deliveredEntries, delivery.entryId, delivery.text, delivery.sourceEntryId);
420
664
  owner.totalEntriesInjected++;
665
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
666
+ lastAcceptedEntryId: delivery.entryId,
667
+ lastInjectionResult: 'delivered',
668
+ lastFailureCode: null,
669
+ });
421
670
  }
422
671
  function scheduleOpenCodeReconciliation(owner, delivery) {
423
672
  if (!delivery.sessionId || owner.reconcilingEntryIds.has(delivery.entryId))
@@ -443,7 +692,8 @@ function scheduleOpenCodeReconciliation(owner, delivery) {
443
692
  }
444
693
  }
445
694
  catch (err) {
446
- log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`);
695
+ recordOpenCodeFailure(owner, err, delivery.sequence);
696
+ log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`, owner);
447
697
  }
448
698
  }
449
699
  }
@@ -492,14 +742,16 @@ async function deliverOpenCodeEntry(owner, delivery) {
492
742
  }
493
743
  if (!target) {
494
744
  try {
495
- target = await resolveInjectionSession();
745
+ target = await resolveInjectionSession(owner, delivery.sequence);
496
746
  }
497
747
  catch (err) {
498
- log(`entry ${delivery.entryId} target unavailable: ${err}`);
748
+ recordOpenCodeFailure(owner, err, delivery.sequence);
749
+ log(`entry ${delivery.entryId} target unavailable: ${err}`, owner);
499
750
  continue;
500
751
  }
501
752
  if (!target) {
502
- log(`entry ${delivery.entryId} target unavailable: no bound session`);
753
+ recordOpenCodeFailure(owner, openCodeHttpError(404, 'session'), delivery.sequence);
754
+ log(`entry ${delivery.entryId} target unavailable: no bound session`, owner);
503
755
  return 'failed';
504
756
  }
505
757
  delivery.sessionId = target.id;
@@ -512,13 +764,14 @@ async function deliverOpenCodeEntry(owner, delivery) {
512
764
  ? null
513
765
  : await findInjectedMessage(confirmationSessionId, delivery.sourceEntryId));
514
766
  if (deliveredIdentity) {
515
- log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}`);
767
+ log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}`, owner);
516
768
  clearPendingSubmission(owner, delivery.entryId);
517
769
  return 'delivered';
518
770
  }
519
771
  }
520
772
  catch (err) {
521
- log(`entry ${delivery.entryId} confirmation unavailable: ${err}`);
773
+ recordOpenCodeFailure(owner, err, delivery.sequence);
774
+ log(`entry ${delivery.entryId} confirmation unavailable: ${err}`, owner);
522
775
  continue;
523
776
  }
524
777
  const submittedBefore = pendingSubmission !== undefined;
@@ -540,7 +793,7 @@ async function deliverOpenCodeEntry(owner, delivery) {
540
793
  });
541
794
  if (!persistCurrentBinding()) {
542
795
  owner.pendingSubmissions.delete(delivery.entryId);
543
- log(`entry ${delivery.entryId} submission skipped: pending intent was not durable`);
796
+ log(`entry ${delivery.entryId} submission skipped: pending intent was not durable`, owner);
544
797
  return 'failed';
545
798
  }
546
799
  // prompt_async is not idempotent. Persist the intent before the one POST;
@@ -559,16 +812,20 @@ async function deliverOpenCodeEntry(owner, delivery) {
559
812
  });
560
813
  }
561
814
  catch (err) {
562
- log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
815
+ recordOpenCodeFailure(owner, err, delivery.sequence);
816
+ log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`, owner);
563
817
  }
564
818
  delivery.state = 'delivered-unconfirmed';
565
819
  if (status !== null && status !== 200 && status !== 204) {
820
+ recordOpenCodeFailure(owner, openCodeHttpError(status, 'prompt'), delivery.sequence);
566
821
  clearPendingSubmission(owner, delivery.entryId);
567
822
  if (status === 404)
568
823
  clearBinding();
569
824
  return 'failed';
570
825
  }
571
826
  delivery.acceptedSubmission = true;
827
+ if (status === 200 || status === 204)
828
+ recordOpenCodeAcceptance(owner, delivery);
572
829
  }
573
830
  for (let confirmationAttempt = 0; confirmationAttempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; confirmationAttempt++) {
574
831
  if (confirmationAttempt > 0) {
@@ -588,7 +845,8 @@ async function deliverOpenCodeEntry(owner, delivery) {
588
845
  }
589
846
  }
590
847
  catch (err) {
591
- log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`);
848
+ recordOpenCodeFailure(owner, err, delivery.sequence);
849
+ log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`, owner);
592
850
  }
593
851
  }
594
852
  return 'delivered-unconfirmed';
@@ -605,13 +863,27 @@ async function processOpenCodeDeliveries(owner) {
605
863
  try {
606
864
  while (state === owner && owner.deliveryQueue.length > 0) {
607
865
  const delivery = owner.deliveryQueue.shift();
866
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
867
+ lastInjectionAt: Date.now(),
868
+ lastInjectionResult: null,
869
+ lastFailureCode: null,
870
+ });
608
871
  let outcome = 'failed';
609
872
  try {
610
873
  outcome = await deliverOpenCodeEntry(owner, delivery);
611
874
  }
612
875
  catch (err) {
613
- log(`entry ${delivery.entryId} delivery error: ${err}`);
876
+ recordOpenCodeFailure(owner, err, delivery.sequence);
877
+ log(`entry ${delivery.entryId} delivery error: ${err}`, owner);
614
878
  }
879
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
880
+ lastInjectionResult: outcome,
881
+ lastFailureCode: outcome === 'delivered'
882
+ ? null
883
+ : outcome === 'failed'
884
+ ? (owner.lastObservation.lastFailureCode ?? 'unknown')
885
+ : owner.lastObservation.lastFailureCode,
886
+ });
615
887
  owner.activeDeliveries.delete(delivery.entryId);
616
888
  if (delivery.settled) {
617
889
  delivery.resolve(true);
@@ -647,12 +919,13 @@ async function processOpenCodeDeliveries(owner) {
647
919
  * the separate MCP-child process, which must never fall back to a newest-session heuristic.
648
920
  */
649
921
  export async function injectInitialKickoff(launch) {
650
- if (!state?.connected) {
651
- log('kickoff: not connected');
922
+ const owner = state;
923
+ if (!owner?.connected) {
924
+ log('kickoff: not connected', owner);
652
925
  return false;
653
926
  }
654
927
  if (!isOpenCode256BitIdentity(launch.correlationIdentity)) {
655
- log('kickoff: correlation identity missing or unverifiable');
928
+ log('kickoff: correlation identity missing or unverifiable', owner);
656
929
  return false;
657
930
  }
658
931
  try {
@@ -660,7 +933,7 @@ export async function injectInitialKickoff(launch) {
660
933
  for (let i = 0; i < 30; i++) {
661
934
  try {
662
935
  await listSessions();
663
- log(`kickoff: server ready (attempt ${i + 1})`);
936
+ log(`kickoff: server ready (attempt ${i + 1})`, owner);
664
937
  break;
665
938
  }
666
939
  catch {
@@ -673,17 +946,19 @@ export async function injectInitialKickoff(launch) {
673
946
  for (let i = 0; i < 30; i++) {
674
947
  const binding = await findLaunchSession(launch.correlationIdentity);
675
948
  if (binding) {
949
+ if (state !== owner)
950
+ return false;
676
951
  saveBinding(binding.session, binding.knownRootSessionIds);
677
- log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`);
952
+ log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`, owner);
678
953
  return true;
679
954
  }
680
955
  await new Promise((r) => setTimeout(r, 1000));
681
956
  }
682
- log('kickoff: no session found');
957
+ log('kickoff: no session found', owner);
683
958
  return false;
684
959
  }
685
960
  catch (err) {
686
- log(`kickoff error: ${err}`);
961
+ log(`kickoff error: ${err}`, owner);
687
962
  return false;
688
963
  }
689
964
  }
@@ -698,7 +973,7 @@ export async function injectInitialKickoff(launch) {
698
973
  export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(text).digest('hex'), allowSubmit = true, sourceEntryId = entryId, isSourcePending) {
699
974
  const owner = state;
700
975
  if (!owner?.connected) {
701
- log(`entry ${entryId} rejected: OpenCode is not connected`);
976
+ log(`entry ${entryId} rejected: OpenCode is not connected`, owner);
702
977
  return Promise.resolve(false);
703
978
  }
704
979
  // Rehydrate durable source markers before source-level deduplication. A
@@ -708,14 +983,14 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
708
983
  restoreBinding();
709
984
  const pendingSource = [...owner.pendingSubmissions].find(([pendingEntryId, pending]) => pendingEntryId !== entryId && pending.sourceEntryId === sourceEntryId);
710
985
  if (pendingSource) {
711
- log(`entry ${entryId} reconciles pending source ${sourceEntryId}`);
986
+ log(`entry ${entryId} reconciles pending source ${sourceEntryId}`, owner);
712
987
  return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId, isSourcePending);
713
988
  }
714
989
  for (const [deliveredEntryId, record] of owner.deliveredEntries) {
715
990
  if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
716
991
  if (record.text !== text)
717
992
  return Promise.resolve(false);
718
- log(`entry ${entryId} source ${sourceEntryId} already delivered`);
993
+ log(`entry ${entryId} source ${sourceEntryId} already delivered`, owner);
719
994
  return Promise.resolve(true);
720
995
  }
721
996
  }
@@ -723,7 +998,7 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
723
998
  if (unconfirmedEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
724
999
  if (record.text !== text)
725
1000
  return Promise.resolve(false);
726
- log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed`);
1001
+ log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed`, owner);
727
1002
  return Promise.resolve(true);
728
1003
  }
729
1004
  }
@@ -731,32 +1006,33 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
731
1006
  if (active.entryId !== entryId && active.sourceEntryId === sourceEntryId) {
732
1007
  if (active.text !== text)
733
1008
  return Promise.resolve(false);
734
- log(`entry ${entryId} joined active source ${sourceEntryId}`);
1009
+ log(`entry ${entryId} joined active source ${sourceEntryId}`, owner);
735
1010
  return active.promise;
736
1011
  }
737
1012
  }
738
1013
  const delivered = owner.deliveredEntries.get(entryId);
739
1014
  if (delivered !== undefined) {
740
1015
  if (delivered.text !== text || delivered.sourceEntryId !== sourceEntryId) {
741
- log(`entry ${entryId} replay text mismatch`);
1016
+ log(`entry ${entryId} replay text mismatch`, owner);
742
1017
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
743
1018
  return Promise.resolve(false);
744
1019
  }
745
- log(`entry ${entryId} replay already delivered`);
1020
+ log(`entry ${entryId} replay already delivered`, owner);
746
1021
  return Promise.resolve(true);
747
1022
  }
748
1023
  const unconfirmed = owner.unconfirmedEntries.get(entryId);
749
1024
  if (unconfirmed !== undefined) {
750
1025
  if (unconfirmed.text !== text || unconfirmed.sourceEntryId !== sourceEntryId) {
751
- log(`entry ${entryId} unconfirmed replay text mismatch`);
1026
+ log(`entry ${entryId} unconfirmed replay text mismatch`, owner);
752
1027
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
753
1028
  return Promise.resolve(false);
754
1029
  }
755
- log(`entry ${entryId} replay remains unconfirmed`);
1030
+ log(`entry ${entryId} replay remains unconfirmed`, owner);
756
1031
  const pending = owner.pendingSubmissions.get(entryId);
757
1032
  const accepted = pending !== undefined;
758
1033
  if (pending) {
759
1034
  scheduleOpenCodeReconciliation(owner, {
1035
+ sequence: ++owner.nextObservationSequence,
760
1036
  entryId,
761
1037
  sourceEntryId,
762
1038
  text,
@@ -774,11 +1050,11 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
774
1050
  const active = owner.activeDeliveries.get(entryId);
775
1051
  if (active) {
776
1052
  if (active.text !== text || active.sourceEntryId !== sourceEntryId) {
777
- log(`entry ${entryId} active text mismatch`);
1053
+ log(`entry ${entryId} active text mismatch`, owner);
778
1054
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
779
1055
  return Promise.resolve(false);
780
1056
  }
781
- log(`entry ${entryId} replay joined active delivery`);
1057
+ log(`entry ${entryId} replay joined active delivery`, owner);
782
1058
  return active.promise;
783
1059
  }
784
1060
  let resolveDelivery;
@@ -786,6 +1062,7 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
786
1062
  resolveDelivery = resolve;
787
1063
  });
788
1064
  const delivery = {
1065
+ sequence: ++owner.nextObservationSequence,
789
1066
  entryId,
790
1067
  sourceEntryId,
791
1068
  text,
@@ -833,19 +1110,26 @@ export function settleOpenCodeEntry(sourceEntryId) {
833
1110
  persistCurrentBinding();
834
1111
  }
835
1112
  export async function probeOpenCodeDroneArmed() {
836
- if (!state?.connected)
1113
+ const owner = state;
1114
+ if (!owner?.connected)
837
1115
  return null;
1116
+ const observationSequence = ++owner.nextObservationSequence;
838
1117
  const binding = restoreBinding();
839
1118
  if (!binding)
840
1119
  return false;
841
1120
  try {
842
1121
  const session = await getSession(binding.sessionId);
1122
+ if (state !== owner)
1123
+ return null;
843
1124
  if (session && isBoundSession(session, binding))
844
1125
  return true;
1126
+ recordOpenCodeFailure(owner, openCodeHttpError(404, 'session'), observationSequence);
845
1127
  clearBinding();
846
1128
  return false;
847
1129
  }
848
- catch {
1130
+ catch (error) {
1131
+ if (state === owner)
1132
+ recordOpenCodeFailure(owner, error, observationSequence);
849
1133
  return false;
850
1134
  }
851
1135
  }
@@ -868,9 +1152,23 @@ export function getOpenCodeConnectionState() {
868
1152
  sessionId: state?.sessionId ?? null,
869
1153
  totalEntriesInjected: state?.totalEntriesInjected ?? 0,
870
1154
  totalEntriesRetried: state?.totalEntriesRetried ?? 0,
1155
+ lastInjectionAt: state?.lastObservation.lastInjectionAt ?? null,
1156
+ lastInjectionResult: state?.lastObservation.lastInjectionResult ?? null,
1157
+ lastAcceptedEntryId: state?.lastObservation.lastAcceptedEntryId ?? null,
1158
+ lastFailureCode: state?.lastObservation.lastFailureCode ?? null,
871
1159
  deliveryStates,
872
1160
  };
873
1161
  }
1162
+ export function __getOpenCodeDiagnosticLogPathForTests() {
1163
+ if (!state)
1164
+ throw new Error('OpenCode drone is not connected');
1165
+ return diagnosticLogPath(state);
1166
+ }
1167
+ export function __getOpenCodeLastObservationForTests() {
1168
+ if (!state)
1169
+ throw new Error('OpenCode drone is not connected');
1170
+ return { ...state.lastObservation };
1171
+ }
874
1172
  export function computeOpenCodePort(droneId, base = 14096) {
875
1173
  let hash = 0;
876
1174
  for (let i = 0; i < droneId.length; i++) {
@@ -938,5 +1236,14 @@ export function __resetOpenCodeDroneForTests() {
938
1236
  }
939
1237
  }
940
1238
  bindingPathsForTests.clear();
1239
+ for (const path of diagnosticLogPathsForTests) {
1240
+ try {
1241
+ unlinkSync(path);
1242
+ }
1243
+ catch {
1244
+ // Already removed.
1245
+ }
1246
+ }
1247
+ diagnosticLogPathsForTests.clear();
941
1248
  }
942
1249
  //# sourceMappingURL=opencode-drone.js.map