borgmcp 3.8.0 → 3.10.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.
- package/SECURITY.md +1 -1
- package/dist/claude.js +3 -3
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.js +5 -5
- package/dist/cli-help.js.map +1 -1
- package/dist/cubes.js +2 -2
- package/dist/cubes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +6 -2
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +36 -11
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-drone.d.ts +7 -2
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +291 -64
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-plugin.d.ts.map +1 -1
- package/dist/opencode-plugin.js +12 -2
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +15 -1
- package/dist/regen-format.js.map +1 -1
- package/dist/remote-client.d.ts +13 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +25 -0
- package/dist/remote-client.js.map +1 -1
- package/dist/seat-commands.js +12 -12
- package/dist/seat-commands.js.map +1 -1
- package/dist/stream-status.d.ts.map +1 -1
- package/dist/stream-status.js +1 -0
- package/dist/stream-status.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -1
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/RELEASING.md +96 -25
- package/package.json +2 -2
- package/src/claude.ts +3 -3
- package/src/cli-help.ts +5 -5
- package/src/cubes.ts +2 -2
- package/src/index.ts +4 -1
- package/src/log-stream.ts +49 -12
- package/src/opencode-drone.ts +328 -65
- package/src/opencode-plugin.ts +13 -1
- package/src/regen-format.ts +15 -1
- package/src/remote-client.ts +36 -0
- package/src/seat-commands.ts +12 -12
- package/src/stream-status.ts +3 -0
- package/src/unknown-subcommand.ts +1 -1
package/src/opencode-drone.ts
CHANGED
|
@@ -27,9 +27,11 @@ interface OpenCodeDroneState {
|
|
|
27
27
|
totalEntriesRetried: number;
|
|
28
28
|
deliveryQueue: OpenCodeDelivery[];
|
|
29
29
|
activeDeliveries: Map<string, OpenCodeDelivery>;
|
|
30
|
-
deliveredEntries: Map<string,
|
|
31
|
-
unconfirmedEntries: Map<string,
|
|
32
|
-
failedEntries: Map<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:
|
|
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 ===
|
|
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
|
|
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:
|
|
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,
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
668
|
+
const submittedBefore = pendingSubmission !== undefined;
|
|
669
|
+
if (!delivery.allowSubmit && !submittedBefore) {
|
|
518
670
|
continue;
|
|
519
671
|
}
|
|
520
672
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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(
|
|
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 (
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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(
|
|
599
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
685
|
-
if (
|
|
686
|
-
if (
|
|
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
|
-
|
|
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();
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -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)
|
|
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
|
}
|
package/src/regen-format.ts
CHANGED
|
@@ -303,6 +303,9 @@ ${arrivalInstruction}
|
|
|
303
303
|
|
|
304
304
|
**Pre-commit git hygiene (universal):**
|
|
305
305
|
|
|
306
|
+
One seat uses one stable worktree. Start each new item there with \`git checkout -b <branch>\`; do not create another worktree for the item.
|
|
307
|
+
A reviewing seat uses \`git checkout --detach <SHA>\` in its own worktree. Keep scratch work under \`~/.borg/scratch/<seat>/\`; load \`borg_playbook\` for the full mechanism.
|
|
308
|
+
|
|
306
309
|
Any drone that commits code: run \`git diff --staged --stat\` before \`git commit\` to verify file count + LOC direction + paths match your intent. Catches deleted files / anomalous -LOC / wrong paths pre-push. Your role may layer more git rules (code-implementing + coordinating roles typically carry the full set).`;
|
|
307
310
|
}
|
|
308
311
|
|
|
@@ -354,7 +357,18 @@ The discipline applies at FOUR surfaces. Catches at the surface closest to origi
|
|
|
354
357
|
- **Surface 3 (review-time verification)**: the existing review-class discipline (Code Reviewer formal gates + Security Auditor SR gates + PM/UX/QA courtesy reviews). Late catch opportunity; if the error propagated through Surfaces 1 + 2, multiple reviewers may have already trusted the framing instead of source-grepping themselves.
|
|
355
358
|
- **Surface 4 (durable-tracking-artifact-writing time)**: when filing a deferred-tracking issue from a cube event payload, the FILING drone fetches the originating entry's full body from the cube log BEFORE composing the issue body. For routine wake triage, use \`borg_read-log unread_only=true\` and drain until caught up; do not rely on a truncated event preview or a \`since=<same timestamp>\` read, which can skip the boundary entry. Cube event previews can truncate substantive content (mid-paragraph cuts on long entries); filing from the truncated preview trusts a derivative artifact instead of the source-of-truth full entry. Most expensive surface — the filed issue becomes the cube's durable cross-cycle memory; correcting it requires a follow-up correction post, and later pickup drones inherit the incomplete framing if the correction is missed.
|
|
356
359
|
|
|
357
|
-
**Ratified-decision drift is a four-surface drift-class.** A ratified cube decision restated from memory drifts exactly like a code-identifier claim — it propagates dispatch (Surface 1, brainstorm) → copy (Surface 2, comment) → gate (Surface 3, review), and the cheapest catch is at the brainstorm surface. At each surface, a drone restating a ratified decision source-reads \`borg_decisions {topic}\` FIRST: the active registry entry is the source of truth; your memory is a derivative artifact. Core rule — **cite ratified decisions by topic; never restate one from memory
|
|
360
|
+
**Ratified-decision drift is a four-surface drift-class.** A ratified cube decision restated from memory drifts exactly like a code-identifier claim — it propagates dispatch (Surface 1, brainstorm) → copy (Surface 2, comment) → gate (Surface 3, review), and the cheapest catch is at the brainstorm surface. At each surface, a drone restating a ratified decision source-reads \`borg_decisions {topic}\` FIRST: the active registry entry is the source of truth; your memory is a derivative artifact. Core rule — **cite ratified decisions by topic; never restate one from memory.**
|
|
361
|
+
|
|
362
|
+
**Worktree and git mechanism:**
|
|
363
|
+
|
|
364
|
+
- One seat uses one stable worktree, created once at assimilation under the standard worktree root and approved once by the operator. All seats for a repository use worktrees from the same clone family, sharing its object database and refs.
|
|
365
|
+
- Start each new work item in that stable worktree with \`git checkout -b <branch>\`. Do not create a new worktree or folder for each item.
|
|
366
|
+
- Hand a branch to another seat only through an explicit log event.
|
|
367
|
+
- Treat branch history as shared: another seat may have the branch checked out or fetched, so never rebase or force-push it.
|
|
368
|
+
- Hand over a ref and exact commit SHA, never a filesystem path. A reviewing seat checks out the SHA in its own worktree with \`git checkout --detach <SHA>\` and never reads another seat's folder.
|
|
369
|
+
- With no hosted remote, the commit is the durable handover artifact because clone-family worktrees share refs; omit the push step. If local push/fetch semantics are required, use a local bare repository as the origin path.
|
|
370
|
+
- Put detached review checkouts, clean-environment rigs, fake HOMEs, unpacked artifacts, and throwaway worktrees under \`~/.borg/scratch/<your-seat-label>/\`. Never use \`/tmp\` or an ad-hoc path. Scratch contents are disposable and must be cleaned up with the work.
|
|
371
|
+
- When an origin exists, synchronize with merge-only history using \`git fetch origin && git merge origin/main\`.`;
|
|
358
372
|
}
|
|
359
373
|
|
|
360
374
|
/**
|