borgmcp 4.2.3 → 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.
Files changed (46) hide show
  1. package/dist/cli-help.d.ts.map +1 -1
  2. package/dist/cli-help.js +5 -2
  3. package/dist/cli-help.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +40 -9
  6. package/dist/index.js.map +1 -1
  7. package/dist/opencode-drone.d.ts +17 -0
  8. package/dist/opencode-drone.d.ts.map +1 -1
  9. package/dist/opencode-drone.js +306 -66
  10. package/dist/opencode-drone.js.map +1 -1
  11. package/dist/opencode-seat-identity.d.ts +1 -1
  12. package/dist/opencode-seat-identity.d.ts.map +1 -1
  13. package/dist/opencode-seat-identity.js.map +1 -1
  14. package/dist/remote-client.d.ts +23 -19
  15. package/dist/remote-client.d.ts.map +1 -1
  16. package/dist/remote-client.js +70 -6
  17. package/dist/remote-client.js.map +1 -1
  18. package/dist/roster-render.d.ts.map +1 -1
  19. package/dist/roster-render.js +2 -3
  20. package/dist/roster-render.js.map +1 -1
  21. package/dist/server-errors.d.ts +22 -0
  22. package/dist/server-errors.d.ts.map +1 -1
  23. package/dist/server-errors.js +32 -0
  24. package/dist/server-errors.js.map +1 -1
  25. package/dist/stream-status.d.ts.map +1 -1
  26. package/dist/stream-status.js +7 -2
  27. package/dist/stream-status.js.map +1 -1
  28. package/dist/tool-manifest.d.ts.map +1 -1
  29. package/dist/tool-manifest.js +17 -4
  30. package/dist/tool-manifest.js.map +1 -1
  31. package/dist/update-cmd.d.ts +2 -1
  32. package/dist/update-cmd.d.ts.map +1 -1
  33. package/dist/update-cmd.js +110 -41
  34. package/dist/update-cmd.js.map +1 -1
  35. package/docs/LOCAL_SERVER.md +14 -5
  36. package/package.json +1 -1
  37. package/src/cli-help.ts +5 -2
  38. package/src/index.ts +49 -9
  39. package/src/opencode-drone.ts +363 -72
  40. package/src/opencode-seat-identity.ts +1 -0
  41. package/src/remote-client.ts +99 -10
  42. package/src/roster-render.ts +2 -3
  43. package/src/server-errors.ts +50 -0
  44. package/src/stream-status.ts +7 -2
  45. package/src/tool-manifest.ts +17 -4
  46. package/src/update-cmd.ts +118 -41
@@ -1,17 +1,48 @@
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';
5
5
  import { tmpdir } from 'os';
6
6
  import { OPENCODE_INJECTED_ENTRY_METADATA_KEY, OPENCODE_WAKE_IDENTITY_METADATA_KEY, OPENCODE_LAUNCH_CORRELATION_METADATA_KEY, } from './opencode-plugin.js';
7
7
  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) {
8
+ import { OpenCodeAuthenticationError, OpenCodeHttpError, OpenCodeResponseError, OpenCodeUnreachableError, } from './server-errors.js';
9
+ const OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES = 64 * 1024;
10
+ const diagnosticLogPathsForTests = new Set();
11
+ function stateIdentityDigest(current) {
12
+ const key = [current.serverUrl, current.directory, current.cubeName, current.droneLabel].join('\0');
13
+ return createHash('sha256').update(key).digest('hex').slice(0, 24);
14
+ }
15
+ function diagnosticLogPath(owner) {
16
+ const path = join(tmpdir(), `borg-opencode-drone-${stateIdentityDigest(owner)}.log`);
17
+ diagnosticLogPathsForTests.add(path);
18
+ return path;
19
+ }
20
+ function log(msg, owner = state) {
10
21
  const line = `[${new Date().toISOString()}] ${msg}\n`;
22
+ if (!owner) {
23
+ process.stderr.write(line);
24
+ return;
25
+ }
11
26
  try {
12
- appendFileSync(LOG_FILE, line);
27
+ const path = diagnosticLogPath(owner);
28
+ if (existsSync(path))
29
+ chmodSync(path, 0o600);
30
+ appendFileSync(path, line, { encoding: 'utf8', mode: 0o600 });
31
+ chmodSync(path, 0o600);
32
+ if (statSync(path).size <= OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES)
33
+ return;
34
+ const contents = readFileSync(path);
35
+ const tail = contents.subarray(contents.length - OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES);
36
+ const firstNewline = tail.indexOf(0x0a);
37
+ const bounded = firstNewline >= 0 ? tail.subarray(firstNewline + 1) : tail;
38
+ const temporary = `${path}.${process.pid}.tmp`;
39
+ writeFileSync(temporary, bounded, { mode: 0o600 });
40
+ renameSync(temporary, path);
41
+ }
42
+ catch (error) {
43
+ const code = error?.code ?? 'unknown';
44
+ process.stderr.write(`OpenCode diagnostic log write failed (${code})\n`);
13
45
  }
14
- catch { }
15
46
  }
16
47
  let state = null;
17
48
  const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000];
@@ -46,7 +77,7 @@ function abandonOpenCodeDeliveries(current) {
46
77
  }
47
78
  export async function connectOpenCodeDrone(deps) {
48
79
  if (!isOpenCode256BitIdentity(deps.apiPassword)) {
49
- throw new Error('OpenCode API password is missing or unverifiable');
80
+ throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
50
81
  }
51
82
  abandonOpenCodeDeliveries(state);
52
83
  state = {
@@ -69,8 +100,18 @@ export async function connectOpenCodeDrone(deps) {
69
100
  pendingSubmissions: new Map(),
70
101
  reconcilingEntryIds: new Set(),
71
102
  processingDeliveries: false,
103
+ nextObservationSequence: 0,
104
+ lastObservation: {
105
+ injectionSequence: 0,
106
+ acceptedSequence: 0,
107
+ failureSequence: 0,
108
+ lastInjectionAt: null,
109
+ lastInjectionResult: null,
110
+ lastAcceptedEntryId: null,
111
+ lastFailureCode: null,
112
+ },
72
113
  };
73
- log(`connected url=${deps.serverUrl} dir=${deps.directory}`);
114
+ log(`connected url=${deps.serverUrl} dir=${deps.directory}`, state);
74
115
  }
75
116
  // ---------------------------------------------------------------------------
76
117
  // Raw fetch wrappers
@@ -82,7 +123,7 @@ function apiUrl(path) {
82
123
  function authenticatedHeaders(headers = {}) {
83
124
  const password = state?.apiPassword;
84
125
  if (!isOpenCode256BitIdentity(password)) {
85
- throw new Error('OpenCode API password is missing or unverifiable');
126
+ throw new OpenCodeAuthenticationError('OpenCode API password is missing or unverifiable');
86
127
  }
87
128
  return {
88
129
  ...headers,
@@ -99,6 +140,11 @@ async function rawGet(path) {
99
140
  const body = await res.text();
100
141
  return { status: res.status, body };
101
142
  }
143
+ catch (error) {
144
+ if (error instanceof OpenCodeAuthenticationError)
145
+ throw error;
146
+ throw new OpenCodeUnreachableError(controller.signal.aborted ? 'timeout' : 'transient', controller.signal.aborted ? 'OpenCode request timed out' : 'OpenCode request failed', { cause: error });
147
+ }
102
148
  finally {
103
149
  clearTimeout(timer);
104
150
  }
@@ -117,29 +163,100 @@ async function rawPost(path, bodyObj) {
117
163
  const body = await res.text();
118
164
  return { status: res.status, body };
119
165
  }
166
+ catch (error) {
167
+ if (error instanceof OpenCodeAuthenticationError)
168
+ throw error;
169
+ throw new OpenCodeUnreachableError(controller.signal.aborted ? 'timeout' : 'transient', controller.signal.aborted ? 'OpenCode request timed out' : 'OpenCode request failed', { cause: error });
170
+ }
120
171
  finally {
121
172
  clearTimeout(timer);
122
173
  }
123
174
  }
175
+ function openCodeHttpError(status, operation) {
176
+ const code = status === 401
177
+ ? 'unauthorized'
178
+ : status === 404
179
+ ? 'not-found'
180
+ : status >= 500 || status === 429
181
+ ? 'transient'
182
+ : 'incompatible-api';
183
+ return new OpenCodeHttpError(status, code, `OpenCode ${operation} request failed (${status})`);
184
+ }
185
+ function parseOpenCodeJson(body) {
186
+ try {
187
+ return JSON.parse(body);
188
+ }
189
+ catch (error) {
190
+ throw new OpenCodeResponseError('OpenCode returned malformed JSON', { cause: error });
191
+ }
192
+ }
193
+ function isRecord(value) {
194
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
195
+ }
196
+ function decodeSession(value) {
197
+ if (!isRecord(value)
198
+ || typeof value.id !== 'string'
199
+ || value.id.length === 0
200
+ || typeof value.directory !== 'string'
201
+ || !isRecord(value.time)
202
+ || typeof value.time.created !== 'number'
203
+ || !Number.isFinite(value.time.created)
204
+ || (value.parentID !== undefined && typeof value.parentID !== 'string')
205
+ || (value.agent !== undefined && typeof value.agent !== 'string')
206
+ || (value.model !== undefined && (!isRecord(value.model)
207
+ || typeof value.model.providerID !== 'string'
208
+ || typeof value.model.modelID !== 'string'))) {
209
+ throw new OpenCodeResponseError();
210
+ }
211
+ return value;
212
+ }
213
+ function decodeSessions(body) {
214
+ const value = parseOpenCodeJson(body);
215
+ if (!Array.isArray(value))
216
+ throw new OpenCodeResponseError();
217
+ return value.map(decodeSession);
218
+ }
219
+ function decodeMessages(body) {
220
+ const value = parseOpenCodeJson(body);
221
+ if (!Array.isArray(value))
222
+ throw new OpenCodeResponseError();
223
+ return value.map((message) => {
224
+ if (!isRecord(message))
225
+ throw new OpenCodeResponseError();
226
+ if (!isRecord(message.info)
227
+ || typeof message.info.role !== 'string'
228
+ || (message.info.id !== undefined && typeof message.info.id !== 'string')
229
+ || (message.info.time !== undefined && (!isRecord(message.info.time)
230
+ || (message.info.time.created !== undefined && typeof message.info.time.created !== 'number'))))
231
+ throw new OpenCodeResponseError();
232
+ if (!Array.isArray(message.parts)
233
+ || message.parts.some((part) => !isRecord(part)
234
+ || (part.type !== undefined && typeof part.type !== 'string')
235
+ || (part.text !== undefined && typeof part.text !== 'string')
236
+ || (part.metadata !== undefined && !isRecord(part.metadata))))
237
+ throw new OpenCodeResponseError();
238
+ return message;
239
+ });
240
+ }
124
241
  async function listSessions() {
125
242
  const { status, body } = await rawGet('/session');
126
243
  if (status !== 200)
127
- throw new Error(`OpenCode sessions request failed (${status})`);
128
- return JSON.parse(body);
244
+ throw openCodeHttpError(status, 'sessions');
245
+ return decodeSessions(body);
129
246
  }
130
247
  async function getSession(id) {
131
248
  const { status, body } = await rawGet(`/session/${id}`);
132
249
  if (status === 404)
133
250
  return null;
134
251
  if (status !== 200)
135
- throw new Error(`OpenCode session request failed (${status})`);
136
- return JSON.parse(body);
252
+ throw openCodeHttpError(status, 'session');
253
+ return decodeSession(parseOpenCodeJson(body));
137
254
  }
138
255
  async function listSessionMessages(id) {
139
256
  const { status, body } = await rawGet(`/session/${id}/message`);
140
257
  if (status !== 200)
141
- throw new Error(`OpenCode session messages request failed (${status})`);
142
- return JSON.parse(body);
258
+ throw openCodeHttpError(status, 'session messages');
259
+ return decodeMessages(body);
143
260
  }
144
261
  async function findInjectedMessage(sessionId, expectedWakeIdentity) {
145
262
  const messages = await listSessionMessages(sessionId);
@@ -165,9 +282,7 @@ async function promptSession(id, bodyObj) {
165
282
  // ---------------------------------------------------------------------------
166
283
  function bindingPath() {
167
284
  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`);
285
+ const path = join(tmpdir(), `borg-opencode-session-${stateIdentityDigest(current)}.json`);
171
286
  bindingPathsForTests.add(path);
172
287
  return path;
173
288
  }
@@ -310,25 +425,20 @@ function restoreBinding() {
310
425
  return binding;
311
426
  }
312
427
  function isBoundSession(session, binding) {
313
- return session.id === binding.sessionId && session.directory === state.directory;
428
+ return session.id === binding.sessionId && session.directory === binding.directory;
314
429
  }
315
430
  function isTopLevelSession(session) {
316
431
  return !session.parentID;
317
432
  }
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 {
433
+ async function findUnseenTopLevelSession(knownRootSessionIds, directory) {
434
+ const sessions = await listSessions();
435
+ const roots = sessions.filter((session) => session.directory === directory
436
+ && isTopLevelSession(session));
437
+ const matched = roots.filter((session) => !knownRootSessionIds.includes(session.id));
438
+ if (matched.length === 0)
330
439
  return null;
331
- }
440
+ const best = matched.reduce((a, b) => a.time.created > b.time.created ? a : b);
441
+ return { session: best, knownRootSessionIds: roots.map((session) => session.id) };
332
442
  }
333
443
  function launchCorrelationMatchCount(messages, correlationIdentity) {
334
444
  let count = 0;
@@ -351,8 +461,13 @@ function launchCorrelationMatchCount(messages, correlationIdentity) {
351
461
  * therefore allowed only when it received this launch's correlation metadata.
352
462
  */
353
463
  async function findLaunchSession(correlationIdentity) {
464
+ const owner = state;
465
+ const observationSequence = ++owner.nextObservationSequence;
354
466
  try {
355
- const sessions = (await listSessions()).filter((session) => session.directory === state.directory);
467
+ const listedSessions = await listSessions();
468
+ if (state !== owner)
469
+ return null;
470
+ const sessions = listedSessions.filter((session) => session.directory === owner.directory);
356
471
  const knownRootSessionIds = sessions
357
472
  .filter(isTopLevelSession)
358
473
  .map((session) => session.id);
@@ -360,24 +475,34 @@ async function findLaunchSession(correlationIdentity) {
360
475
  session,
361
476
  matchCount: launchCorrelationMatchCount(await listSessionMessages(session.id), correlationIdentity),
362
477
  })));
478
+ if (state !== owner)
479
+ return null;
363
480
  const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
364
481
  if (totalMatches !== 1)
365
482
  return null;
366
483
  const matched = candidates.find((candidate) => candidate.matchCount === 1);
367
484
  return matched ? { session: matched.session, knownRootSessionIds } : null;
368
485
  }
369
- catch {
486
+ catch (error) {
487
+ if (state === owner)
488
+ recordOpenCodeFailure(owner, error, observationSequence);
370
489
  return null;
371
490
  }
372
491
  }
373
- async function resolveInjectionSession() {
492
+ async function resolveInjectionSession(owner, observationSequence) {
493
+ if (state !== owner)
494
+ return null;
374
495
  const binding = restoreBinding();
375
496
  if (!binding)
376
497
  return null;
377
498
  const bound = await getSession(binding.sessionId);
499
+ if (state !== owner)
500
+ return null;
378
501
  if (!bound || !isBoundSession(bound, binding)) {
379
502
  clearBinding();
380
- const replacement = await findUnseenTopLevelSession(binding.knownRootSessionIds);
503
+ const replacement = await findUnseenTopLevelSession(binding.knownRootSessionIds, owner.directory);
504
+ if (state !== owner)
505
+ return null;
381
506
  if (!replacement)
382
507
  return null;
383
508
  saveBinding(replacement.session, replacement.knownRootSessionIds);
@@ -386,7 +511,15 @@ async function resolveInjectionSession() {
386
511
  // `/new` creates an unseen top-level session. Keep the launch-time root
387
512
  // snapshot so an old, unrelated root is never mistaken for a user switch.
388
513
  // Children never supersede the bound root.
389
- const switched = await findUnseenTopLevelSession(binding.knownRootSessionIds);
514
+ let switched = null;
515
+ try {
516
+ switched = await findUnseenTopLevelSession(binding.knownRootSessionIds, owner.directory);
517
+ }
518
+ catch (error) {
519
+ recordOpenCodeFailure(owner, error, observationSequence);
520
+ }
521
+ if (state !== owner)
522
+ return null;
390
523
  if (switched) {
391
524
  saveBinding(switched.session, switched.knownRootSessionIds);
392
525
  return switched.session;
@@ -403,6 +536,50 @@ function rememberBounded(entries, entryId, text, sourceEntryId) {
403
536
  entries.delete(oldest);
404
537
  }
405
538
  }
539
+ function openCodeFailureCode(error) {
540
+ const code = error?.code;
541
+ if (typeof code === 'string' && code.length > 0)
542
+ return code;
543
+ return error instanceof Error && error.name ? error.name : 'unknown';
544
+ }
545
+ function updateLastOpenCodeObservation(owner, sequence, update) {
546
+ // Attempts, acceptances, and failures resolve independently; an observation
547
+ // may be stale for one field without being stale for the others.
548
+ const current = owner.lastObservation;
549
+ const updatesInjection = 'lastInjectionAt' in update || 'lastInjectionResult' in update;
550
+ const updatesAccepted = 'lastAcceptedEntryId' in update;
551
+ const updatesFailure = 'lastFailureCode' in update;
552
+ owner.lastObservation = {
553
+ ...current,
554
+ ...(updatesInjection && sequence >= current.injectionSequence
555
+ ? {
556
+ injectionSequence: sequence,
557
+ ...('lastInjectionAt' in update
558
+ ? { lastInjectionAt: update.lastInjectionAt }
559
+ : {}),
560
+ ...('lastInjectionResult' in update
561
+ ? { lastInjectionResult: update.lastInjectionResult }
562
+ : {}),
563
+ }
564
+ : {}),
565
+ ...(updatesAccepted && sequence >= current.acceptedSequence
566
+ ? { acceptedSequence: sequence, lastAcceptedEntryId: update.lastAcceptedEntryId }
567
+ : {}),
568
+ ...(updatesFailure && sequence >= current.failureSequence
569
+ ? { failureSequence: sequence, lastFailureCode: update.lastFailureCode }
570
+ : {}),
571
+ };
572
+ }
573
+ function recordOpenCodeFailure(owner, error, observationSequence) {
574
+ updateLastOpenCodeObservation(owner, observationSequence, {
575
+ lastFailureCode: openCodeFailureCode(error),
576
+ });
577
+ }
578
+ function recordOpenCodeAcceptance(owner, delivery) {
579
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
580
+ lastAcceptedEntryId: delivery.entryId,
581
+ });
582
+ }
406
583
  function clearPendingSubmission(owner, entryId) {
407
584
  if (!owner.pendingSubmissions.delete(entryId))
408
585
  return;
@@ -418,6 +595,11 @@ function confirmOpenCodeDelivery(owner, delivery) {
418
595
  clearPendingSubmission(owner, delivery.entryId);
419
596
  rememberBounded(owner.deliveredEntries, delivery.entryId, delivery.text, delivery.sourceEntryId);
420
597
  owner.totalEntriesInjected++;
598
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
599
+ lastAcceptedEntryId: delivery.entryId,
600
+ lastInjectionResult: 'delivered',
601
+ lastFailureCode: null,
602
+ });
421
603
  }
422
604
  function scheduleOpenCodeReconciliation(owner, delivery) {
423
605
  if (!delivery.sessionId || owner.reconcilingEntryIds.has(delivery.entryId))
@@ -443,7 +625,8 @@ function scheduleOpenCodeReconciliation(owner, delivery) {
443
625
  }
444
626
  }
445
627
  catch (err) {
446
- log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`);
628
+ recordOpenCodeFailure(owner, err, delivery.sequence);
629
+ log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`, owner);
447
630
  }
448
631
  }
449
632
  }
@@ -492,14 +675,16 @@ async function deliverOpenCodeEntry(owner, delivery) {
492
675
  }
493
676
  if (!target) {
494
677
  try {
495
- target = await resolveInjectionSession();
678
+ target = await resolveInjectionSession(owner, delivery.sequence);
496
679
  }
497
680
  catch (err) {
498
- log(`entry ${delivery.entryId} target unavailable: ${err}`);
681
+ recordOpenCodeFailure(owner, err, delivery.sequence);
682
+ log(`entry ${delivery.entryId} target unavailable: ${err}`, owner);
499
683
  continue;
500
684
  }
501
685
  if (!target) {
502
- log(`entry ${delivery.entryId} target unavailable: no bound session`);
686
+ recordOpenCodeFailure(owner, openCodeHttpError(404, 'session'), delivery.sequence);
687
+ log(`entry ${delivery.entryId} target unavailable: no bound session`, owner);
503
688
  return 'failed';
504
689
  }
505
690
  delivery.sessionId = target.id;
@@ -512,13 +697,14 @@ async function deliverOpenCodeEntry(owner, delivery) {
512
697
  ? null
513
698
  : await findInjectedMessage(confirmationSessionId, delivery.sourceEntryId));
514
699
  if (deliveredIdentity) {
515
- log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}`);
700
+ log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}`, owner);
516
701
  clearPendingSubmission(owner, delivery.entryId);
517
702
  return 'delivered';
518
703
  }
519
704
  }
520
705
  catch (err) {
521
- log(`entry ${delivery.entryId} confirmation unavailable: ${err}`);
706
+ recordOpenCodeFailure(owner, err, delivery.sequence);
707
+ log(`entry ${delivery.entryId} confirmation unavailable: ${err}`, owner);
522
708
  continue;
523
709
  }
524
710
  const submittedBefore = pendingSubmission !== undefined;
@@ -540,7 +726,7 @@ async function deliverOpenCodeEntry(owner, delivery) {
540
726
  });
541
727
  if (!persistCurrentBinding()) {
542
728
  owner.pendingSubmissions.delete(delivery.entryId);
543
- log(`entry ${delivery.entryId} submission skipped: pending intent was not durable`);
729
+ log(`entry ${delivery.entryId} submission skipped: pending intent was not durable`, owner);
544
730
  return 'failed';
545
731
  }
546
732
  // prompt_async is not idempotent. Persist the intent before the one POST;
@@ -559,16 +745,20 @@ async function deliverOpenCodeEntry(owner, delivery) {
559
745
  });
560
746
  }
561
747
  catch (err) {
562
- log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
748
+ recordOpenCodeFailure(owner, err, delivery.sequence);
749
+ log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`, owner);
563
750
  }
564
751
  delivery.state = 'delivered-unconfirmed';
565
752
  if (status !== null && status !== 200 && status !== 204) {
753
+ recordOpenCodeFailure(owner, openCodeHttpError(status, 'prompt'), delivery.sequence);
566
754
  clearPendingSubmission(owner, delivery.entryId);
567
755
  if (status === 404)
568
756
  clearBinding();
569
757
  return 'failed';
570
758
  }
571
759
  delivery.acceptedSubmission = true;
760
+ if (status === 200 || status === 204)
761
+ recordOpenCodeAcceptance(owner, delivery);
572
762
  }
573
763
  for (let confirmationAttempt = 0; confirmationAttempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; confirmationAttempt++) {
574
764
  if (confirmationAttempt > 0) {
@@ -588,7 +778,8 @@ async function deliverOpenCodeEntry(owner, delivery) {
588
778
  }
589
779
  }
590
780
  catch (err) {
591
- log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`);
781
+ recordOpenCodeFailure(owner, err, delivery.sequence);
782
+ log(`entry ${delivery.entryId} post-acceptance confirmation unavailable: ${err}`, owner);
592
783
  }
593
784
  }
594
785
  return 'delivered-unconfirmed';
@@ -605,13 +796,27 @@ async function processOpenCodeDeliveries(owner) {
605
796
  try {
606
797
  while (state === owner && owner.deliveryQueue.length > 0) {
607
798
  const delivery = owner.deliveryQueue.shift();
799
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
800
+ lastInjectionAt: Date.now(),
801
+ lastInjectionResult: null,
802
+ lastFailureCode: null,
803
+ });
608
804
  let outcome = 'failed';
609
805
  try {
610
806
  outcome = await deliverOpenCodeEntry(owner, delivery);
611
807
  }
612
808
  catch (err) {
613
- log(`entry ${delivery.entryId} delivery error: ${err}`);
809
+ recordOpenCodeFailure(owner, err, delivery.sequence);
810
+ log(`entry ${delivery.entryId} delivery error: ${err}`, owner);
614
811
  }
812
+ updateLastOpenCodeObservation(owner, delivery.sequence, {
813
+ lastInjectionResult: outcome,
814
+ lastFailureCode: outcome === 'delivered'
815
+ ? null
816
+ : outcome === 'failed'
817
+ ? (owner.lastObservation.lastFailureCode ?? 'unknown')
818
+ : owner.lastObservation.lastFailureCode,
819
+ });
615
820
  owner.activeDeliveries.delete(delivery.entryId);
616
821
  if (delivery.settled) {
617
822
  delivery.resolve(true);
@@ -647,12 +852,13 @@ async function processOpenCodeDeliveries(owner) {
647
852
  * the separate MCP-child process, which must never fall back to a newest-session heuristic.
648
853
  */
649
854
  export async function injectInitialKickoff(launch) {
650
- if (!state?.connected) {
651
- log('kickoff: not connected');
855
+ const owner = state;
856
+ if (!owner?.connected) {
857
+ log('kickoff: not connected', owner);
652
858
  return false;
653
859
  }
654
860
  if (!isOpenCode256BitIdentity(launch.correlationIdentity)) {
655
- log('kickoff: correlation identity missing or unverifiable');
861
+ log('kickoff: correlation identity missing or unverifiable', owner);
656
862
  return false;
657
863
  }
658
864
  try {
@@ -660,7 +866,7 @@ export async function injectInitialKickoff(launch) {
660
866
  for (let i = 0; i < 30; i++) {
661
867
  try {
662
868
  await listSessions();
663
- log(`kickoff: server ready (attempt ${i + 1})`);
869
+ log(`kickoff: server ready (attempt ${i + 1})`, owner);
664
870
  break;
665
871
  }
666
872
  catch {
@@ -673,17 +879,19 @@ export async function injectInitialKickoff(launch) {
673
879
  for (let i = 0; i < 30; i++) {
674
880
  const binding = await findLaunchSession(launch.correlationIdentity);
675
881
  if (binding) {
882
+ if (state !== owner)
883
+ return false;
676
884
  saveBinding(binding.session, binding.knownRootSessionIds);
677
- log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`);
885
+ log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`, owner);
678
886
  return true;
679
887
  }
680
888
  await new Promise((r) => setTimeout(r, 1000));
681
889
  }
682
- log('kickoff: no session found');
890
+ log('kickoff: no session found', owner);
683
891
  return false;
684
892
  }
685
893
  catch (err) {
686
- log(`kickoff error: ${err}`);
894
+ log(`kickoff error: ${err}`, owner);
687
895
  return false;
688
896
  }
689
897
  }
@@ -698,7 +906,7 @@ export async function injectInitialKickoff(launch) {
698
906
  export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(text).digest('hex'), allowSubmit = true, sourceEntryId = entryId, isSourcePending) {
699
907
  const owner = state;
700
908
  if (!owner?.connected) {
701
- log(`entry ${entryId} rejected: OpenCode is not connected`);
909
+ log(`entry ${entryId} rejected: OpenCode is not connected`, owner);
702
910
  return Promise.resolve(false);
703
911
  }
704
912
  // Rehydrate durable source markers before source-level deduplication. A
@@ -708,14 +916,14 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
708
916
  restoreBinding();
709
917
  const pendingSource = [...owner.pendingSubmissions].find(([pendingEntryId, pending]) => pendingEntryId !== entryId && pending.sourceEntryId === sourceEntryId);
710
918
  if (pendingSource) {
711
- log(`entry ${entryId} reconciles pending source ${sourceEntryId}`);
919
+ log(`entry ${entryId} reconciles pending source ${sourceEntryId}`, owner);
712
920
  return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId, isSourcePending);
713
921
  }
714
922
  for (const [deliveredEntryId, record] of owner.deliveredEntries) {
715
923
  if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
716
924
  if (record.text !== text)
717
925
  return Promise.resolve(false);
718
- log(`entry ${entryId} source ${sourceEntryId} already delivered`);
926
+ log(`entry ${entryId} source ${sourceEntryId} already delivered`, owner);
719
927
  return Promise.resolve(true);
720
928
  }
721
929
  }
@@ -723,7 +931,7 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
723
931
  if (unconfirmedEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
724
932
  if (record.text !== text)
725
933
  return Promise.resolve(false);
726
- log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed`);
934
+ log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed`, owner);
727
935
  return Promise.resolve(true);
728
936
  }
729
937
  }
@@ -731,32 +939,33 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
731
939
  if (active.entryId !== entryId && active.sourceEntryId === sourceEntryId) {
732
940
  if (active.text !== text)
733
941
  return Promise.resolve(false);
734
- log(`entry ${entryId} joined active source ${sourceEntryId}`);
942
+ log(`entry ${entryId} joined active source ${sourceEntryId}`, owner);
735
943
  return active.promise;
736
944
  }
737
945
  }
738
946
  const delivered = owner.deliveredEntries.get(entryId);
739
947
  if (delivered !== undefined) {
740
948
  if (delivered.text !== text || delivered.sourceEntryId !== sourceEntryId) {
741
- log(`entry ${entryId} replay text mismatch`);
949
+ log(`entry ${entryId} replay text mismatch`, owner);
742
950
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
743
951
  return Promise.resolve(false);
744
952
  }
745
- log(`entry ${entryId} replay already delivered`);
953
+ log(`entry ${entryId} replay already delivered`, owner);
746
954
  return Promise.resolve(true);
747
955
  }
748
956
  const unconfirmed = owner.unconfirmedEntries.get(entryId);
749
957
  if (unconfirmed !== undefined) {
750
958
  if (unconfirmed.text !== text || unconfirmed.sourceEntryId !== sourceEntryId) {
751
- log(`entry ${entryId} unconfirmed replay text mismatch`);
959
+ log(`entry ${entryId} unconfirmed replay text mismatch`, owner);
752
960
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
753
961
  return Promise.resolve(false);
754
962
  }
755
- log(`entry ${entryId} replay remains unconfirmed`);
963
+ log(`entry ${entryId} replay remains unconfirmed`, owner);
756
964
  const pending = owner.pendingSubmissions.get(entryId);
757
965
  const accepted = pending !== undefined;
758
966
  if (pending) {
759
967
  scheduleOpenCodeReconciliation(owner, {
968
+ sequence: ++owner.nextObservationSequence,
760
969
  entryId,
761
970
  sourceEntryId,
762
971
  text,
@@ -774,11 +983,11 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
774
983
  const active = owner.activeDeliveries.get(entryId);
775
984
  if (active) {
776
985
  if (active.text !== text || active.sourceEntryId !== sourceEntryId) {
777
- log(`entry ${entryId} active text mismatch`);
986
+ log(`entry ${entryId} active text mismatch`, owner);
778
987
  rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
779
988
  return Promise.resolve(false);
780
989
  }
781
- log(`entry ${entryId} replay joined active delivery`);
990
+ log(`entry ${entryId} replay joined active delivery`, owner);
782
991
  return active.promise;
783
992
  }
784
993
  let resolveDelivery;
@@ -786,6 +995,7 @@ export function injectOpenCodeEntry(text, entryId = createHash('sha256').update(
786
995
  resolveDelivery = resolve;
787
996
  });
788
997
  const delivery = {
998
+ sequence: ++owner.nextObservationSequence,
789
999
  entryId,
790
1000
  sourceEntryId,
791
1001
  text,
@@ -833,19 +1043,26 @@ export function settleOpenCodeEntry(sourceEntryId) {
833
1043
  persistCurrentBinding();
834
1044
  }
835
1045
  export async function probeOpenCodeDroneArmed() {
836
- if (!state?.connected)
1046
+ const owner = state;
1047
+ if (!owner?.connected)
837
1048
  return null;
1049
+ const observationSequence = ++owner.nextObservationSequence;
838
1050
  const binding = restoreBinding();
839
1051
  if (!binding)
840
1052
  return false;
841
1053
  try {
842
1054
  const session = await getSession(binding.sessionId);
1055
+ if (state !== owner)
1056
+ return null;
843
1057
  if (session && isBoundSession(session, binding))
844
1058
  return true;
1059
+ recordOpenCodeFailure(owner, openCodeHttpError(404, 'session'), observationSequence);
845
1060
  clearBinding();
846
1061
  return false;
847
1062
  }
848
- catch {
1063
+ catch (error) {
1064
+ if (state === owner)
1065
+ recordOpenCodeFailure(owner, error, observationSequence);
849
1066
  return false;
850
1067
  }
851
1068
  }
@@ -868,9 +1085,23 @@ export function getOpenCodeConnectionState() {
868
1085
  sessionId: state?.sessionId ?? null,
869
1086
  totalEntriesInjected: state?.totalEntriesInjected ?? 0,
870
1087
  totalEntriesRetried: state?.totalEntriesRetried ?? 0,
1088
+ lastInjectionAt: state?.lastObservation.lastInjectionAt ?? null,
1089
+ lastInjectionResult: state?.lastObservation.lastInjectionResult ?? null,
1090
+ lastAcceptedEntryId: state?.lastObservation.lastAcceptedEntryId ?? null,
1091
+ lastFailureCode: state?.lastObservation.lastFailureCode ?? null,
871
1092
  deliveryStates,
872
1093
  };
873
1094
  }
1095
+ export function __getOpenCodeDiagnosticLogPathForTests() {
1096
+ if (!state)
1097
+ throw new Error('OpenCode drone is not connected');
1098
+ return diagnosticLogPath(state);
1099
+ }
1100
+ export function __getOpenCodeLastObservationForTests() {
1101
+ if (!state)
1102
+ throw new Error('OpenCode drone is not connected');
1103
+ return { ...state.lastObservation };
1104
+ }
874
1105
  export function computeOpenCodePort(droneId, base = 14096) {
875
1106
  let hash = 0;
876
1107
  for (let i = 0; i < droneId.length; i++) {
@@ -938,5 +1169,14 @@ export function __resetOpenCodeDroneForTests() {
938
1169
  }
939
1170
  }
940
1171
  bindingPathsForTests.clear();
1172
+ for (const path of diagnosticLogPathsForTests) {
1173
+ try {
1174
+ unlinkSync(path);
1175
+ }
1176
+ catch {
1177
+ // Already removed.
1178
+ }
1179
+ }
1180
+ diagnosticLogPathsForTests.clear();
941
1181
  }
942
1182
  //# sourceMappingURL=opencode-drone.js.map