borgmcp 3.9.0 → 3.11.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 (85) hide show
  1. package/SECURITY.md +1 -1
  2. package/dist/assimilate-cmd.d.ts +14 -1
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +19 -12
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/claude.d.ts.map +1 -1
  7. package/dist/claude.js +25 -3
  8. package/dist/claude.js.map +1 -1
  9. package/dist/cli-help.d.ts +2 -0
  10. package/dist/cli-help.d.ts.map +1 -1
  11. package/dist/cli-help.js +33 -6
  12. package/dist/cli-help.js.map +1 -1
  13. package/dist/clone-cmd.d.ts +28 -0
  14. package/dist/clone-cmd.d.ts.map +1 -0
  15. package/dist/clone-cmd.js +227 -0
  16. package/dist/clone-cmd.js.map +1 -0
  17. package/dist/clone-security.d.ts +9 -0
  18. package/dist/clone-security.d.ts.map +1 -0
  19. package/dist/clone-security.js +41 -0
  20. package/dist/clone-security.js.map +1 -0
  21. package/dist/cubes.js +2 -2
  22. package/dist/cubes.js.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/launch-all-cmd.d.ts +11 -0
  27. package/dist/launch-all-cmd.d.ts.map +1 -1
  28. package/dist/launch-all-cmd.js +24 -2
  29. package/dist/launch-all-cmd.js.map +1 -1
  30. package/dist/log-stream.d.ts +6 -2
  31. package/dist/log-stream.d.ts.map +1 -1
  32. package/dist/log-stream.js +36 -11
  33. package/dist/log-stream.js.map +1 -1
  34. package/dist/opencode-drone.d.ts +7 -2
  35. package/dist/opencode-drone.d.ts.map +1 -1
  36. package/dist/opencode-drone.js +291 -64
  37. package/dist/opencode-drone.js.map +1 -1
  38. package/dist/opencode-plugin.d.ts.map +1 -1
  39. package/dist/opencode-plugin.js +12 -2
  40. package/dist/opencode-plugin.js.map +1 -1
  41. package/dist/parse-clone-args.d.ts +17 -0
  42. package/dist/parse-clone-args.d.ts.map +1 -0
  43. package/dist/parse-clone-args.js +38 -0
  44. package/dist/parse-clone-args.js.map +1 -0
  45. package/dist/parse-quickstart-args.d.ts +18 -0
  46. package/dist/parse-quickstart-args.d.ts.map +1 -0
  47. package/dist/parse-quickstart-args.js +43 -0
  48. package/dist/parse-quickstart-args.js.map +1 -0
  49. package/dist/quickstart-cmd.d.ts +23 -0
  50. package/dist/quickstart-cmd.d.ts.map +1 -0
  51. package/dist/quickstart-cmd.js +357 -0
  52. package/dist/quickstart-cmd.js.map +1 -0
  53. package/dist/remote-client.d.ts +13 -0
  54. package/dist/remote-client.d.ts.map +1 -1
  55. package/dist/remote-client.js +25 -0
  56. package/dist/remote-client.js.map +1 -1
  57. package/dist/seat-commands.js +12 -12
  58. package/dist/seat-commands.js.map +1 -1
  59. package/dist/stream-status.d.ts.map +1 -1
  60. package/dist/stream-status.js +1 -0
  61. package/dist/stream-status.js.map +1 -1
  62. package/dist/unknown-subcommand.d.ts +1 -1
  63. package/dist/unknown-subcommand.d.ts.map +1 -1
  64. package/dist/unknown-subcommand.js +3 -1
  65. package/dist/unknown-subcommand.js.map +1 -1
  66. package/docs/RELEASING.md +94 -23
  67. package/package.json +1 -1
  68. package/src/assimilate-cmd.ts +35 -12
  69. package/src/claude.ts +25 -3
  70. package/src/cli-help.ts +39 -6
  71. package/src/clone-cmd.ts +243 -0
  72. package/src/clone-security.ts +40 -0
  73. package/src/cubes.ts +2 -2
  74. package/src/index.ts +4 -1
  75. package/src/launch-all-cmd.ts +37 -2
  76. package/src/log-stream.ts +49 -12
  77. package/src/opencode-drone.ts +328 -65
  78. package/src/opencode-plugin.ts +13 -1
  79. package/src/parse-clone-args.ts +44 -0
  80. package/src/parse-quickstart-args.ts +54 -0
  81. package/src/quickstart-cmd.ts +396 -0
  82. package/src/remote-client.ts +36 -0
  83. package/src/seat-commands.ts +12 -12
  84. package/src/stream-status.ts +3 -0
  85. package/src/unknown-subcommand.ts +3 -1
@@ -27,9 +27,11 @@ interface OpenCodeDroneState {
27
27
  totalEntriesRetried: number;
28
28
  deliveryQueue: OpenCodeDelivery[];
29
29
  activeDeliveries: Map<string, OpenCodeDelivery>;
30
- deliveredEntries: Map<string, string>;
31
- unconfirmedEntries: Map<string, string>;
32
- failedEntries: Map<string, string>;
30
+ deliveredEntries: Map<string, OpenCodeDeliveryRecord>;
31
+ unconfirmedEntries: Map<string, OpenCodeDeliveryRecord>;
32
+ failedEntries: Map<string, OpenCodeDeliveryRecord>;
33
+ pendingSubmissions: Map<string, PendingOpenCodeSubmission>;
34
+ reconcilingEntryIds: Set<string>;
33
35
  processingDeliveries: boolean;
34
36
  }
35
37
 
@@ -72,20 +74,40 @@ export type OpenCodeDeliveryState =
72
74
 
73
75
  interface OpenCodeDelivery {
74
76
  entryId: string;
77
+ sourceEntryId: string;
75
78
  text: string;
76
79
  allowSubmit: boolean;
80
+ acceptedSubmission: boolean;
81
+ sessionId: string | null;
82
+ settled: boolean;
77
83
  state: Exclude<OpenCodeDeliveryState, 'failed'>;
78
84
  resolve: (delivered: boolean) => void;
79
85
  promise: Promise<boolean>;
80
86
  }
81
87
 
88
+ interface OpenCodeDeliveryRecord {
89
+ text: string;
90
+ sourceEntryId: string;
91
+ }
92
+
93
+ interface PendingOpenCodeSubmission {
94
+ sourceEntryId: string;
95
+ /** Session that received the one allowed prompt_async submission. */
96
+ sessionId: string;
97
+ }
98
+
82
99
  type OpenCodeDeliveryOutcome = 'delivered' | 'delivered-unconfirmed' | 'failed';
83
100
 
84
101
  const OPEN_CODE_DELIVERY_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const;
102
+ const OPEN_CODE_RECONCILIATION_DELAY_MS = 3_000;
103
+ // Keep one accepted-but-unconfirmed prompt from holding timers forever. It
104
+ // remains pending (never failed or resubmitted), and any later wake retry
105
+ // re-arms another confirmation-only window.
106
+ const OPEN_CODE_RECONCILIATION_ATTEMPTS = 20;
85
107
  const OPEN_CODE_DELIVERY_HISTORY_LIMIT = 256;
86
108
 
87
109
  interface SessionBinding {
88
- version: 2;
110
+ version: 4;
89
111
  sessionId: string;
90
112
  sessionCreatedAt: number;
91
113
  knownRootSessionIds: string[];
@@ -93,6 +115,11 @@ interface SessionBinding {
93
115
  directory: string;
94
116
  droneLabel: string;
95
117
  cubeName: string;
118
+ pendingSubmissions: Array<{
119
+ entryId: string;
120
+ sourceEntryId: string;
121
+ sessionId: string;
122
+ }>;
96
123
  }
97
124
 
98
125
  export interface OpenCodeLaunchKickoff {
@@ -151,6 +178,8 @@ export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
151
178
  deliveredEntries: new Map(),
152
179
  unconfirmedEntries: new Map(),
153
180
  failedEntries: new Map(),
181
+ pendingSubmissions: new Map(),
182
+ reconcilingEntryIds: new Set(),
154
183
  processingDeliveries: false,
155
184
  };
156
185
  log(`connected url=${deps.serverUrl} dir=${deps.directory}`);
@@ -257,7 +286,7 @@ function bindingPath(): string {
257
286
 
258
287
  function bindingMatchesState(binding: SessionBinding): boolean {
259
288
  const current = state!;
260
- return binding.version === 2
289
+ return binding.version === 4
261
290
  && binding.serverUrl === current.serverUrl
262
291
  && binding.directory === current.directory
263
292
  && binding.droneLabel === current.droneLabel
@@ -265,7 +294,13 @@ function bindingMatchesState(binding: SessionBinding): boolean {
265
294
  && typeof binding.sessionId === 'string'
266
295
  && typeof binding.sessionCreatedAt === 'number'
267
296
  && Array.isArray(binding.knownRootSessionIds)
268
- && binding.knownRootSessionIds.every((id) => typeof id === 'string');
297
+ && binding.knownRootSessionIds.every((id) => typeof id === 'string')
298
+ && Array.isArray(binding.pendingSubmissions)
299
+ && binding.pendingSubmissions.every((pending) =>
300
+ typeof pending?.entryId === 'string'
301
+ && typeof pending?.sourceEntryId === 'string'
302
+ && typeof pending.sessionId === 'string'
303
+ );
269
304
  }
270
305
 
271
306
  function readBinding(): SessionBinding | null {
@@ -285,6 +320,10 @@ function clearBinding(): void {
285
320
  state.sessionId = null;
286
321
  state.sessionCreatedAt = null;
287
322
  state.knownRootSessionIds = [];
323
+ // A missing/replaced target does not prove that an earlier prompt_async was
324
+ // rejected. Keep its durable submission marker (and origin session) so a
325
+ // replacement binding and later MCP-child reconnect remain confirmation-only.
326
+ if (state.pendingSubmissions.size > 0) return;
288
327
  try {
289
328
  unlinkSync(path);
290
329
  } catch {
@@ -292,18 +331,8 @@ function clearBinding(): void {
292
331
  }
293
332
  }
294
333
 
295
- function saveBinding(session: OCSession, knownRootSessionIds: string[]): void {
334
+ function writeBinding(binding: SessionBinding): boolean {
296
335
  const current = state!;
297
- const binding: SessionBinding = {
298
- version: 2,
299
- sessionId: session.id,
300
- sessionCreatedAt: session.time.created,
301
- knownRootSessionIds,
302
- serverUrl: current.serverUrl,
303
- directory: current.directory,
304
- droneLabel: current.droneLabel,
305
- cubeName: current.cubeName,
306
- };
307
336
  current.sessionId = binding.sessionId;
308
337
  current.sessionCreatedAt = binding.sessionCreatedAt;
309
338
  current.knownRootSessionIds = binding.knownRootSessionIds;
@@ -313,16 +342,58 @@ function saveBinding(session: OCSession, knownRootSessionIds: string[]): void {
313
342
  const temporary = `${path}.${process.pid}.tmp`;
314
343
  writeFileSync(temporary, JSON.stringify(binding), { mode: 0o600 });
315
344
  renameSync(temporary, path);
345
+ return true;
316
346
  } catch (err) {
317
347
  log(`session binding write failed: ${err}`);
348
+ return false;
318
349
  }
319
350
  }
320
351
 
352
+ function saveBinding(session: OCSession, knownRootSessionIds: string[]): void {
353
+ const current = state!;
354
+ const binding: SessionBinding = {
355
+ version: 4,
356
+ sessionId: session.id,
357
+ sessionCreatedAt: session.time.created,
358
+ knownRootSessionIds,
359
+ serverUrl: current.serverUrl,
360
+ directory: current.directory,
361
+ droneLabel: current.droneLabel,
362
+ cubeName: current.cubeName,
363
+ pendingSubmissions: [...current.pendingSubmissions].map(([entryId, pending]) => ({
364
+ entryId,
365
+ sourceEntryId: pending.sourceEntryId,
366
+ sessionId: pending.sessionId,
367
+ })),
368
+ };
369
+ writeBinding(binding);
370
+ }
371
+
372
+ function persistCurrentBinding(): boolean {
373
+ const current = state;
374
+ if (!current?.sessionId || current.sessionCreatedAt === null) return false;
375
+ return writeBinding({
376
+ version: 4,
377
+ sessionId: current.sessionId,
378
+ sessionCreatedAt: current.sessionCreatedAt,
379
+ knownRootSessionIds: current.knownRootSessionIds,
380
+ serverUrl: current.serverUrl,
381
+ directory: current.directory,
382
+ droneLabel: current.droneLabel,
383
+ cubeName: current.cubeName,
384
+ pendingSubmissions: [...current.pendingSubmissions].map(([entryId, pending]) => ({
385
+ entryId,
386
+ sourceEntryId: pending.sourceEntryId,
387
+ sessionId: pending.sessionId,
388
+ })),
389
+ });
390
+ }
391
+
321
392
  function restoreBinding(): SessionBinding | null {
322
393
  if (!state) return null;
323
394
  if (state.sessionId && state.sessionCreatedAt !== null) {
324
395
  return {
325
- version: 2,
396
+ version: 4,
326
397
  sessionId: state.sessionId,
327
398
  sessionCreatedAt: state.sessionCreatedAt,
328
399
  knownRootSessionIds: state.knownRootSessionIds,
@@ -330,6 +401,11 @@ function restoreBinding(): SessionBinding | null {
330
401
  directory: state.directory,
331
402
  droneLabel: state.droneLabel,
332
403
  cubeName: state.cubeName,
404
+ pendingSubmissions: [...state.pendingSubmissions].map(([entryId, pending]) => ({
405
+ entryId,
406
+ sourceEntryId: pending.sourceEntryId,
407
+ sessionId: pending.sessionId,
408
+ })),
333
409
  };
334
410
  }
335
411
 
@@ -338,6 +414,13 @@ function restoreBinding(): SessionBinding | null {
338
414
  state.sessionId = binding.sessionId;
339
415
  state.sessionCreatedAt = binding.sessionCreatedAt;
340
416
  state.knownRootSessionIds = binding.knownRootSessionIds;
417
+ state.pendingSubmissions = new Map(binding.pendingSubmissions.map((pending) => [
418
+ pending.entryId,
419
+ {
420
+ sourceEntryId: pending.sourceEntryId,
421
+ sessionId: pending.sessionId,
422
+ },
423
+ ]));
341
424
  return binding;
342
425
  }
343
426
 
@@ -451,12 +534,13 @@ async function resolveInjectionSession(): Promise<OCSession | null> {
451
534
  }
452
535
 
453
536
  function rememberBounded(
454
- entries: Map<string, string>,
537
+ entries: Map<string, OpenCodeDeliveryRecord>,
455
538
  entryId: string,
456
539
  text: string,
540
+ sourceEntryId: string,
457
541
  ): void {
458
542
  entries.delete(entryId);
459
- entries.set(entryId, text);
543
+ entries.set(entryId, { text, sourceEntryId });
460
544
  while (entries.size > OPEN_CODE_DELIVERY_HISTORY_LIMIT) {
461
545
  const oldest = entries.keys().next().value;
462
546
  if (typeof oldest !== 'string') break;
@@ -464,6 +548,60 @@ function rememberBounded(
464
548
  }
465
549
  }
466
550
 
551
+ function clearPendingSubmission(owner: OpenCodeDroneState, entryId: string): void {
552
+ if (!owner.pendingSubmissions.delete(entryId)) return;
553
+ if (state === owner) persistCurrentBinding();
554
+ }
555
+
556
+ function confirmOpenCodeDelivery(
557
+ owner: OpenCodeDroneState,
558
+ delivery: Pick<OpenCodeDelivery, 'entryId' | 'sourceEntryId' | 'text'>,
559
+ ): void {
560
+ const unconfirmed = owner.unconfirmedEntries.get(delivery.entryId);
561
+ if (unconfirmed && unconfirmed.text !== delivery.text) return;
562
+ owner.unconfirmedEntries.delete(delivery.entryId);
563
+ owner.failedEntries.delete(delivery.entryId);
564
+ clearPendingSubmission(owner, delivery.entryId);
565
+ rememberBounded(
566
+ owner.deliveredEntries,
567
+ delivery.entryId,
568
+ delivery.text,
569
+ delivery.sourceEntryId,
570
+ );
571
+ owner.totalEntriesInjected++;
572
+ }
573
+
574
+ function scheduleOpenCodeReconciliation(
575
+ owner: OpenCodeDroneState,
576
+ delivery: OpenCodeDelivery,
577
+ ): void {
578
+ if (!delivery.sessionId || owner.reconcilingEntryIds.has(delivery.entryId)) return;
579
+ owner.reconcilingEntryIds.add(delivery.entryId);
580
+ const sessionId = delivery.sessionId;
581
+ void (async () => {
582
+ try {
583
+ for (let attempt = 0; attempt < OPEN_CODE_RECONCILIATION_ATTEMPTS; attempt++) {
584
+ await new Promise((resolve) => setTimeout(resolve, OPEN_CODE_RECONCILIATION_DELAY_MS));
585
+ if (state !== owner || !owner.connected || delivery.settled) return;
586
+ const record = owner.unconfirmedEntries.get(delivery.entryId);
587
+ if (!record || record.text !== delivery.text) return;
588
+ owner.totalEntriesRetried++;
589
+ try {
590
+ if (await findInjectedMessage(sessionId, delivery.entryId)) {
591
+ if (delivery.settled) return;
592
+ confirmOpenCodeDelivery(owner, delivery);
593
+ return;
594
+ }
595
+ } catch (err) {
596
+ log(`entry ${delivery.entryId} reconciliation unavailable: ${err}`);
597
+ }
598
+ }
599
+ } finally {
600
+ owner.reconcilingEntryIds.delete(delivery.entryId);
601
+ }
602
+ })();
603
+ }
604
+
467
605
  function waitForDeliveryRetry(attempt: number): Promise<void> {
468
606
  const delay = OPEN_CODE_DELIVERY_RETRY_DELAYS_MS[attempt] ?? 0;
469
607
  return delay > 0
@@ -483,11 +621,13 @@ async function deliverOpenCodeEntry(
483
621
  // ever becoming the active user turn. The unique inbox text correlates the
484
622
  // generated message across confirmation and process-replay instead.
485
623
  for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
624
+ if (delivery.settled) return 'delivered';
486
625
  if (state !== owner || !owner.connected) return 'failed';
487
626
  if (attempt > 0) {
488
627
  delivery.state = 'retried';
489
628
  owner.totalEntriesRetried++;
490
629
  await waitForDeliveryRetry(attempt);
630
+ if (delivery.settled) return 'delivered';
491
631
  if (state !== owner || !owner.connected) return 'failed';
492
632
  }
493
633
 
@@ -502,11 +642,22 @@ async function deliverOpenCodeEntry(
502
642
  log(`entry ${delivery.entryId} target unavailable: no bound session`);
503
643
  return 'failed';
504
644
  }
645
+ delivery.sessionId = target.id;
505
646
  }
506
647
 
648
+ const pendingSubmission = owner.pendingSubmissions.get(delivery.entryId);
649
+ const confirmationSessionId = pendingSubmission?.sessionId ?? target.id;
650
+ delivery.sessionId = confirmationSessionId;
651
+
507
652
  try {
508
- if (await findInjectedMessage(target.id, delivery.entryId)) {
509
- log(`entry ${delivery.entryId} already present in session ${target.id}`);
653
+ const deliveredIdentity = await findInjectedMessage(confirmationSessionId, delivery.entryId) ?? (
654
+ delivery.sourceEntryId === delivery.entryId
655
+ ? null
656
+ : await findInjectedMessage(confirmationSessionId, delivery.sourceEntryId)
657
+ );
658
+ if (deliveredIdentity) {
659
+ log(`entry ${delivery.entryId} already present in session ${confirmationSessionId}`);
660
+ clearPendingSubmission(owner, delivery.entryId);
510
661
  return 'delivered';
511
662
  }
512
663
  } catch (err) {
@@ -514,33 +665,51 @@ async function deliverOpenCodeEntry(
514
665
  continue;
515
666
  }
516
667
 
517
- if (!delivery.allowSubmit) {
668
+ const submittedBefore = pendingSubmission !== undefined;
669
+ if (!delivery.allowSubmit && !submittedBefore) {
518
670
  continue;
519
671
  }
520
672
 
521
- // prompt_async is not idempotent. Submit at most once, then only poll for
522
- // the exact text. A transport failure is ambiguous and follows the same
523
- // confirmation-only path.
524
- let status: number | null = null;
525
- try {
526
- status = await promptSession(target.id, {
527
- parts: [{
528
- type: 'text',
529
- text: delivery.text,
530
- metadata: {
531
- [OPENCODE_INJECTED_ENTRY_METADATA_KEY]: true,
532
- [OPENCODE_WAKE_IDENTITY_METADATA_KEY]: delivery.entryId,
533
- },
534
- }],
673
+ if (submittedBefore) {
674
+ delivery.acceptedSubmission = true;
675
+ delivery.state = 'delivered-unconfirmed';
676
+ } else {
677
+ if (delivery.settled) return 'delivered';
678
+ owner.pendingSubmissions.set(delivery.entryId, {
679
+ sourceEntryId: delivery.sourceEntryId,
680
+ sessionId: target.id,
535
681
  });
536
- } catch (err) {
537
- log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
538
- }
682
+ if (!persistCurrentBinding()) {
683
+ owner.pendingSubmissions.delete(delivery.entryId);
684
+ log(`entry ${delivery.entryId} submission skipped: pending intent was not durable`);
685
+ return 'failed';
686
+ }
539
687
 
540
- delivery.state = 'delivered-unconfirmed';
541
- if (status !== null && status !== 200 && status !== 204) {
542
- if (status === 404) clearBinding();
543
- return 'failed';
688
+ // prompt_async is not idempotent. Persist the intent before the one POST;
689
+ // every recovery path is confirmation-only until that identity appears.
690
+ let status: number | null = null;
691
+ try {
692
+ status = await promptSession(target.id, {
693
+ parts: [{
694
+ type: 'text',
695
+ text: delivery.text,
696
+ metadata: {
697
+ [OPENCODE_INJECTED_ENTRY_METADATA_KEY]: true,
698
+ [OPENCODE_WAKE_IDENTITY_METADATA_KEY]: delivery.entryId,
699
+ },
700
+ }],
701
+ });
702
+ } catch (err) {
703
+ log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
704
+ }
705
+
706
+ delivery.state = 'delivered-unconfirmed';
707
+ if (status !== null && status !== 200 && status !== 204) {
708
+ clearPendingSubmission(owner, delivery.entryId);
709
+ if (status === 404) clearBinding();
710
+ return 'failed';
711
+ }
712
+ delivery.acceptedSubmission = true;
544
713
  }
545
714
 
546
715
  for (
@@ -552,11 +721,13 @@ async function deliverOpenCodeEntry(
552
721
  delivery.state = 'retried';
553
722
  owner.totalEntriesRetried++;
554
723
  await waitForDeliveryRetry(confirmationAttempt);
724
+ if (delivery.settled) return 'delivered';
555
725
  if (state !== owner || !owner.connected) return 'delivered-unconfirmed';
556
726
  delivery.state = 'delivered-unconfirmed';
557
727
  }
558
728
  try {
559
- if (await findInjectedMessage(target.id, delivery.entryId)) {
729
+ if (await findInjectedMessage(delivery.sessionId!, delivery.entryId)) {
730
+ clearPendingSubmission(owner, delivery.entryId);
560
731
  return 'delivered';
561
732
  }
562
733
  } catch (err) {
@@ -587,19 +758,29 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
587
758
  }
588
759
 
589
760
  owner.activeDeliveries.delete(delivery.entryId);
590
- if (outcome === 'delivered') {
591
- owner.unconfirmedEntries.delete(delivery.entryId);
592
- owner.failedEntries.delete(delivery.entryId);
593
- rememberBounded(owner.deliveredEntries, delivery.entryId, delivery.text);
594
- owner.totalEntriesInjected++;
761
+ if (delivery.settled) {
762
+ delivery.resolve(true);
763
+ } else if (outcome === 'delivered') {
764
+ confirmOpenCodeDelivery(owner, delivery);
595
765
  delivery.resolve(true);
596
766
  } else if (outcome === 'delivered-unconfirmed') {
597
767
  owner.failedEntries.delete(delivery.entryId);
598
- rememberBounded(owner.unconfirmedEntries, delivery.entryId, delivery.text);
599
- delivery.resolve(false);
768
+ rememberBounded(
769
+ owner.unconfirmedEntries,
770
+ delivery.entryId,
771
+ delivery.text,
772
+ delivery.sourceEntryId,
773
+ );
774
+ delivery.resolve(delivery.acceptedSubmission);
775
+ if (delivery.acceptedSubmission) scheduleOpenCodeReconciliation(owner, delivery);
600
776
  } else {
601
777
  owner.unconfirmedEntries.delete(delivery.entryId);
602
- rememberBounded(owner.failedEntries, delivery.entryId, delivery.text);
778
+ rememberBounded(
779
+ owner.failedEntries,
780
+ delivery.entryId,
781
+ delivery.text,
782
+ delivery.sourceEntryId,
783
+ );
603
784
  delivery.resolve(false);
604
785
  }
605
786
  }
@@ -657,12 +838,15 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
657
838
  * Queue one durable inbox entry for delivery into the bound OpenCode session.
658
839
  * The delivery identity is stored in TextPart metadata, so retries and replay
659
840
  * can confirm an earlier ambiguous submission without supplying an
660
- * ordering-breaking caller message ID or exposing the identity in delivered text.
841
+ * ordering-breaking caller message ID or exposing the identity in delivered
842
+ * text. Retry nonces also carry their durable source entry ID so they reconcile
843
+ * one submission instead of creating a second prompt.
661
844
  */
662
845
  export function injectOpenCodeEntry(
663
846
  text: string,
664
847
  entryId: string = createHash('sha256').update(text).digest('hex'),
665
848
  allowSubmit: boolean = true,
849
+ sourceEntryId: string = entryId,
666
850
  ): Promise<boolean> {
667
851
  const owner = state;
668
852
  if (!owner?.connected) {
@@ -670,33 +854,84 @@ export function injectOpenCodeEntry(
670
854
  return Promise.resolve(false);
671
855
  }
672
856
 
673
- const deliveredText = owner.deliveredEntries.get(entryId);
674
- if (deliveredText !== undefined) {
675
- if (deliveredText !== text) {
857
+ // Rehydrate durable source markers before source-level deduplication. A
858
+ // freshly connected MCP child starts with an empty in-memory map; waiting
859
+ // until target resolution would let a different wake nonce queue a second
860
+ // submission before the prior source identity is visible locally.
861
+ restoreBinding();
862
+ const pendingSource = [...owner.pendingSubmissions].find(
863
+ ([pendingEntryId, pending]) =>
864
+ pendingEntryId !== entryId && pending.sourceEntryId === sourceEntryId,
865
+ );
866
+ if (pendingSource) {
867
+ log(`entry ${entryId} reconciles pending source ${sourceEntryId}`);
868
+ return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId);
869
+ }
870
+ for (const [deliveredEntryId, record] of owner.deliveredEntries) {
871
+ if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
872
+ if (record.text !== text) return Promise.resolve(false);
873
+ log(`entry ${entryId} source ${sourceEntryId} already delivered`);
874
+ return Promise.resolve(true);
875
+ }
876
+ }
877
+ for (const [unconfirmedEntryId, record] of owner.unconfirmedEntries) {
878
+ if (unconfirmedEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
879
+ if (record.text !== text) return Promise.resolve(false);
880
+ log(`entry ${entryId} source ${sourceEntryId} remains unconfirmed`);
881
+ return Promise.resolve(true);
882
+ }
883
+ }
884
+ for (const active of owner.activeDeliveries.values()) {
885
+ if (active.entryId !== entryId && active.sourceEntryId === sourceEntryId) {
886
+ if (active.text !== text) return Promise.resolve(false);
887
+ log(`entry ${entryId} joined active source ${sourceEntryId}`);
888
+ return active.promise;
889
+ }
890
+ }
891
+
892
+ const delivered = owner.deliveredEntries.get(entryId);
893
+ if (delivered !== undefined) {
894
+ if (delivered.text !== text || delivered.sourceEntryId !== sourceEntryId) {
676
895
  log(`entry ${entryId} replay text mismatch`);
677
- rememberBounded(owner.failedEntries, entryId, text);
896
+ rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
678
897
  return Promise.resolve(false);
679
898
  }
680
899
  log(`entry ${entryId} replay already delivered`);
681
900
  return Promise.resolve(true);
682
901
  }
683
902
 
684
- const unconfirmedText = owner.unconfirmedEntries.get(entryId);
685
- if (unconfirmedText !== undefined) {
686
- if (unconfirmedText !== text) {
903
+ const unconfirmed = owner.unconfirmedEntries.get(entryId);
904
+ if (unconfirmed !== undefined) {
905
+ if (unconfirmed.text !== text || unconfirmed.sourceEntryId !== sourceEntryId) {
687
906
  log(`entry ${entryId} unconfirmed replay text mismatch`);
688
- rememberBounded(owner.failedEntries, entryId, text);
907
+ rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
689
908
  return Promise.resolve(false);
690
909
  }
691
910
  log(`entry ${entryId} replay remains unconfirmed`);
692
- return Promise.resolve(false);
911
+ const pending = owner.pendingSubmissions.get(entryId);
912
+ const accepted = pending !== undefined;
913
+ if (pending) {
914
+ scheduleOpenCodeReconciliation(owner, {
915
+ entryId,
916
+ sourceEntryId,
917
+ text,
918
+ allowSubmit: false,
919
+ acceptedSubmission: true,
920
+ sessionId: pending.sessionId,
921
+ settled: false,
922
+ state: 'delivered-unconfirmed',
923
+ resolve: () => {},
924
+ promise: Promise.resolve(true),
925
+ });
926
+ }
927
+ return Promise.resolve(accepted);
693
928
  }
694
929
 
695
930
  const active = owner.activeDeliveries.get(entryId);
696
931
  if (active) {
697
- if (active.text !== text) {
932
+ if (active.text !== text || active.sourceEntryId !== sourceEntryId) {
698
933
  log(`entry ${entryId} active text mismatch`);
699
- rememberBounded(owner.failedEntries, entryId, text);
934
+ rememberBounded(owner.failedEntries, entryId, text, sourceEntryId);
700
935
  return Promise.resolve(false);
701
936
  }
702
937
  log(`entry ${entryId} replay joined active delivery`);
@@ -709,8 +944,12 @@ export function injectOpenCodeEntry(
709
944
  });
710
945
  const delivery: OpenCodeDelivery = {
711
946
  entryId,
947
+ sourceEntryId,
712
948
  text,
713
949
  allowSubmit,
950
+ acceptedSubmission: false,
951
+ sessionId: null,
952
+ settled: false,
714
953
  state: 'queued',
715
954
  resolve: resolveDelivery,
716
955
  promise,
@@ -723,6 +962,30 @@ export function injectOpenCodeEntry(
723
962
  return promise;
724
963
  }
725
964
 
965
+ /** Stop retrying every delivery identity derived from a durable entry that the
966
+ * agent has already consumed. Confirmed history stays available for dedup. */
967
+ export function settleOpenCodeEntry(sourceEntryId: string): void {
968
+ const owner = state;
969
+ if (!owner) return;
970
+
971
+ let bindingChanged = false;
972
+ for (const [entryId, record] of owner.unconfirmedEntries) {
973
+ if (record.sourceEntryId !== sourceEntryId) continue;
974
+ owner.unconfirmedEntries.delete(entryId);
975
+ bindingChanged = owner.pendingSubmissions.delete(entryId) || bindingChanged;
976
+ }
977
+ for (const [entryId, record] of owner.failedEntries) {
978
+ if (record.sourceEntryId === sourceEntryId) owner.failedEntries.delete(entryId);
979
+ }
980
+ for (const delivery of owner.activeDeliveries.values()) {
981
+ if (delivery.sourceEntryId !== sourceEntryId) continue;
982
+ delivery.settled = true;
983
+ delivery.resolve(true);
984
+ bindingChanged = owner.pendingSubmissions.delete(delivery.entryId) || bindingChanged;
985
+ }
986
+ if (bindingChanged) persistCurrentBinding();
987
+ }
988
+
726
989
  export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
727
990
  if (!state?.connected) return null;
728
991
  const binding = restoreBinding();
@@ -138,7 +138,19 @@ export function createOpenCodePluginCore(
138
138
  try {
139
139
  const history = await deps.listMessages(input.sessionID);
140
140
  const nudge = deps.audit([...history, current]);
141
- if (nudge) output.parts.push({ type: 'text', text: nudge });
141
+ if (nudge) {
142
+ // OpenCode's chat.message hook receives resolved parts after their
143
+ // id/sessionID/messageID fields have been assigned. A newly pushed
144
+ // part skips that assignment and fails the durable PartUpdated
145
+ // aggregate in Session.updatePart. Preserve the resolved identity by
146
+ // replacing an existing text part with an augmented copy instead.
147
+ const index = output.parts.findIndex((part) =>
148
+ part?.type === 'text' && typeof part.text === 'string');
149
+ if (index >= 0) {
150
+ const part = output.parts[index];
151
+ output.parts[index] = { ...part, text: `${part.text}\n\n${nudge}` };
152
+ }
153
+ }
142
154
  } catch {
143
155
  // Audit is advisory and must never block a prompt.
144
156
  }
@@ -0,0 +1,44 @@
1
+ import { redactCloneSecrets } from './clone-security.js';
2
+
3
+ export interface CloneArgs {
4
+ repositoryUrl: string;
5
+ destination?: string;
6
+ noLaunch: boolean;
7
+ }
8
+
9
+ export type ParseCloneResult =
10
+ | { ok: true; args: CloneArgs }
11
+ | { ok: false; error: string };
12
+
13
+ export function parseCloneArgs(rawArgs: readonly string[]): ParseCloneResult {
14
+ let repositoryUrl: string | undefined;
15
+ let destination: string | undefined;
16
+ let noLaunch = false;
17
+ for (const arg of rawArgs) {
18
+ if (arg === '--no-launch') {
19
+ if (noLaunch) return { ok: false, error: '--no-launch was provided more than once' };
20
+ noLaunch = true;
21
+ continue;
22
+ }
23
+ if (arg.startsWith('-')) {
24
+ const option = arg.startsWith('--') ? arg.split('=', 1)[0] : arg.slice(0, 2);
25
+ return { ok: false, error: `unknown option ${option}; the only option is --no-launch` };
26
+ }
27
+ if (repositoryUrl === undefined) repositoryUrl = arg;
28
+ else if (destination === undefined) destination = arg;
29
+ else return { ok: false, error: 'unexpected extra argument' };
30
+ }
31
+ if (!repositoryUrl) return { ok: false, error: 'a repository URL is required' };
32
+ return {
33
+ ok: true,
34
+ args: {
35
+ repositoryUrl,
36
+ ...(destination === undefined ? {} : { destination }),
37
+ noLaunch,
38
+ },
39
+ };
40
+ }
41
+
42
+ export function safeCloneParseError(result: Extract<ParseCloneResult, { ok: false }>): string {
43
+ return redactCloneSecrets(result.error);
44
+ }