borgmcp 3.11.0 → 3.12.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 (69) hide show
  1. package/THIRD_PARTY_NOTICES.md +5 -0
  2. package/dist/assimilate-cmd.d.ts.map +1 -1
  3. package/dist/assimilate-cmd.js +8 -3
  4. package/dist/assimilate-cmd.js.map +1 -1
  5. package/dist/assimilate-deps.d.ts.map +1 -1
  6. package/dist/assimilate-deps.js +11 -0
  7. package/dist/assimilate-deps.js.map +1 -1
  8. package/dist/claude.d.ts.map +1 -1
  9. package/dist/claude.js +11 -4
  10. package/dist/claude.js.map +1 -1
  11. package/dist/codex-app-wake.d.ts +3 -2
  12. package/dist/codex-app-wake.d.ts.map +1 -1
  13. package/dist/codex-app-wake.js +42 -9
  14. package/dist/codex-app-wake.js.map +1 -1
  15. package/dist/config-utils.d.ts +13 -12
  16. package/dist/config-utils.d.ts.map +1 -1
  17. package/dist/config-utils.js +150 -27
  18. package/dist/config-utils.js.map +1 -1
  19. package/dist/cube-activity-wake-copy.d.ts +1 -1
  20. package/dist/cube-activity-wake-copy.d.ts.map +1 -1
  21. package/dist/cube-activity-wake-copy.js +1 -1
  22. package/dist/cube-activity-wake-copy.js.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/log-stream.d.ts +3 -3
  27. package/dist/log-stream.d.ts.map +1 -1
  28. package/dist/log-stream.js +8 -13
  29. package/dist/log-stream.js.map +1 -1
  30. package/dist/opencode-drone.d.ts +11 -9
  31. package/dist/opencode-drone.d.ts.map +1 -1
  32. package/dist/opencode-drone.js +72 -48
  33. package/dist/opencode-drone.js.map +1 -1
  34. package/dist/opencode-launch-trust.d.ts +13 -0
  35. package/dist/opencode-launch-trust.d.ts.map +1 -0
  36. package/dist/opencode-launch-trust.js +40 -0
  37. package/dist/opencode-launch-trust.js.map +1 -0
  38. package/dist/opencode-plugin.d.ts +3 -0
  39. package/dist/opencode-plugin.d.ts.map +1 -1
  40. package/dist/opencode-plugin.js +26 -1
  41. package/dist/opencode-plugin.js.map +1 -1
  42. package/dist/remote-client.d.ts +2 -21
  43. package/dist/remote-client.d.ts.map +1 -1
  44. package/dist/remote-client.js +8 -6
  45. package/dist/remote-client.js.map +1 -1
  46. package/dist/server-handshake.d.ts +6 -1
  47. package/dist/server-handshake.d.ts.map +1 -1
  48. package/dist/server-handshake.js +1 -0
  49. package/dist/server-handshake.js.map +1 -1
  50. package/dist/setup-confirm.d.ts +1 -1
  51. package/dist/setup-confirm.js +3 -3
  52. package/dist/setup-confirm.js.map +1 -1
  53. package/docs/LOCAL_SERVER.md +4 -8
  54. package/docs/RELEASING.md +32 -308
  55. package/package.json +3 -2
  56. package/src/assimilate-cmd.ts +13 -3
  57. package/src/assimilate-deps.ts +11 -0
  58. package/src/claude.ts +16 -4
  59. package/src/codex-app-wake.ts +42 -8
  60. package/src/config-utils.ts +147 -23
  61. package/src/cube-activity-wake-copy.ts +1 -1
  62. package/src/index.ts +7 -0
  63. package/src/log-stream.ts +24 -15
  64. package/src/opencode-drone.ts +85 -55
  65. package/src/opencode-launch-trust.ts +46 -0
  66. package/src/opencode-plugin.ts +31 -1
  67. package/src/remote-client.ts +11 -25
  68. package/src/server-handshake.ts +4 -1
  69. package/src/setup-confirm.ts +3 -3
@@ -1,12 +1,19 @@
1
1
  import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
2
- import { createHash, randomUUID } from 'crypto';
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 {
7
7
  OPENCODE_INJECTED_ENTRY_METADATA_KEY,
8
8
  OPENCODE_WAKE_IDENTITY_METADATA_KEY,
9
+ OPENCODE_LAUNCH_CORRELATION_METADATA_KEY,
9
10
  } from './opencode-plugin.js';
11
+ import {
12
+ createOpenCodeLaunchTrust,
13
+ isOpenCode256BitIdentity,
14
+ OPENCODE_SERVER_USERNAME,
15
+ type OpenCodeLaunchTrust,
16
+ } from './opencode-launch-trust.js';
10
17
 
11
18
  const LOG_FILE = join(tmpdir(), 'borg-opencode-drone.log');
12
19
  function log(msg: string) {
@@ -16,6 +23,7 @@ function log(msg: string) {
16
23
 
17
24
  interface OpenCodeDroneState {
18
25
  serverUrl: string;
26
+ apiPassword: string;
19
27
  sessionId: string | null;
20
28
  sessionCreatedAt: number | null;
21
29
  knownRootSessionIds: string[];
@@ -39,6 +47,7 @@ let state: OpenCodeDroneState | null = null;
39
47
 
40
48
  interface ConnectDeps {
41
49
  serverUrl: string;
50
+ apiPassword: string;
42
51
  directory: string;
43
52
  droneLabel: string;
44
53
  cubeName: string;
@@ -80,6 +89,7 @@ interface OpenCodeDelivery {
80
89
  acceptedSubmission: boolean;
81
90
  sessionId: string | null;
82
91
  settled: boolean;
92
+ isSourcePending?: () => Promise<boolean>;
83
93
  state: Exclude<OpenCodeDeliveryState, 'failed'>;
84
94
  resolve: (delivered: boolean) => void;
85
95
  promise: Promise<boolean>;
@@ -124,27 +134,23 @@ interface SessionBinding {
124
134
 
125
135
  export interface OpenCodeLaunchKickoff {
126
136
  prompt: string;
127
- nonce: string;
137
+ apiPassword: string;
138
+ correlationIdentity: string;
128
139
  }
129
140
 
130
- // This is correlation metadata, intentionally not an instruction to the
131
- // launched agent. A markdown comment keeps it benign in the user-visible
132
- // kickoff while preserving it in OpenCode's stored message text.
133
- const OPEN_CODE_LAUNCH_NONCE_MARKER = 'borg-opencode-correlation:';
134
-
135
141
  /**
136
- * Add a launch-unique identity to the OpenCode-only copy of the shared
137
- * kickoff. The prompt is what OpenCode records as its first user message, so
138
- * the launcher can later bind the MCP child to this precise launch instead of
139
- * guessing from a repeated kickoff's text or timestamp.
142
+ * Create independent launch trust for OpenCode without changing the shared
143
+ * kickoff text. The plugin writes the correlation identity to hidden metadata
144
+ * on the first qualifying human TextPart; the API password stays in env.
140
145
  */
141
146
  export function createOpenCodeLaunchKickoff(
142
147
  kickoff: string,
143
- nonce: string = randomUUID(),
148
+ trust: Partial<OpenCodeLaunchTrust> = {},
144
149
  ): OpenCodeLaunchKickoff {
150
+ const launchTrust = createOpenCodeLaunchTrust(trust);
145
151
  return {
146
- prompt: `${kickoff}\n\n<!-- ${OPEN_CODE_LAUNCH_NONCE_MARKER}${nonce} -->`,
147
- nonce,
152
+ prompt: kickoff,
153
+ ...launchTrust,
148
154
  };
149
155
  }
150
156
 
@@ -161,9 +167,13 @@ function abandonOpenCodeDeliveries(current: OpenCodeDroneState | null): void {
161
167
  }
162
168
 
163
169
  export async function connectOpenCodeDrone(deps: ConnectDeps): Promise<void> {
170
+ if (!isOpenCode256BitIdentity(deps.apiPassword)) {
171
+ throw new Error('OpenCode API password is missing or unverifiable');
172
+ }
164
173
  abandonOpenCodeDeliveries(state);
165
174
  state = {
166
175
  serverUrl: deps.serverUrl,
176
+ apiPassword: deps.apiPassword,
167
177
  sessionId: null,
168
178
  sessionCreatedAt: null,
169
179
  knownRootSessionIds: [],
@@ -194,6 +204,17 @@ function apiUrl(path: string): string {
194
204
  return `${base}${path}${path.includes('?') ? '&' : '?'}directory=${encodeURIComponent(state!.directory)}`;
195
205
  }
196
206
 
207
+ function authenticatedHeaders(headers: Record<string, string> = {}): Record<string, string> {
208
+ const password = state?.apiPassword;
209
+ if (!isOpenCode256BitIdentity(password)) {
210
+ throw new Error('OpenCode API password is missing or unverifiable');
211
+ }
212
+ return {
213
+ ...headers,
214
+ Authorization: `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${password}`).toString('base64')}`,
215
+ };
216
+ }
217
+
197
218
  const FETCH_TIMEOUT = 10_000;
198
219
 
199
220
  async function rawGet(path: string): Promise<{ status: number; body: string }> {
@@ -201,7 +222,7 @@ async function rawGet(path: string): Promise<{ status: number; body: string }> {
201
222
  const controller = new AbortController();
202
223
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
203
224
  try {
204
- const res = await fetch(url, { signal: controller.signal });
225
+ const res = await fetch(url, { headers: authenticatedHeaders(), signal: controller.signal });
205
226
  const body = await res.text();
206
227
  return { status: res.status, body };
207
228
  } finally {
@@ -216,7 +237,7 @@ async function rawPost(path: string, bodyObj: unknown): Promise<{ status: number
216
237
  try {
217
238
  const res = await fetch(url, {
218
239
  method: 'POST',
219
- headers: { 'Content-Type': 'application/json' },
240
+ headers: authenticatedHeaders({ 'Content-Type': 'application/json' }),
220
241
  signal: controller.signal,
221
242
  body: JSON.stringify(bodyObj),
222
243
  });
@@ -453,28 +474,29 @@ async function findUnseenTopLevelSession(knownRootSessionIds: string[]): Promise
453
474
  }
454
475
  }
455
476
 
456
- function kickoffMessageTime(messages: OCMessage[], nonce: string): number | null {
457
- let latest: number | null = null;
477
+ function launchCorrelationMatchCount(messages: OCMessage[], correlationIdentity: string): number {
478
+ let count = 0;
458
479
  for (const message of messages) {
459
- if (message.info?.role && message.info.role !== 'user') continue;
460
- const matchesLaunchNonce = message.parts?.some(
461
- (part) => part.type === 'text' && part.text?.includes(`${OPEN_CODE_LAUNCH_NONCE_MARKER}${nonce}`),
462
- );
463
- if (!matchesLaunchNonce) continue;
464
- const created = message.info?.time?.created ?? 0;
465
- latest = latest === null ? created : Math.max(latest, created);
480
+ if (message.info?.role !== 'user') continue;
481
+ for (const part of message.parts ?? []) {
482
+ if (
483
+ part.type === 'text' &&
484
+ part.metadata?.[OPENCODE_LAUNCH_CORRELATION_METADATA_KEY] === correlationIdentity
485
+ ) {
486
+ count++;
487
+ }
488
+ }
466
489
  }
467
- return latest;
490
+ return count;
468
491
  }
469
492
 
470
493
  /**
471
494
  * The launch process is the only place allowed to discover a session from the
472
- * server. It chooses the session that contains this launch's unique nonce,
473
- * rather than choosing by repeated kickoff text or session creation time. A
474
- * fork is therefore allowed only when it was explicitly selected for this
475
- * launch and received the nonce-bearing kickoff.
495
+ * server. It chooses the session containing exactly one hidden metadata match,
496
+ * never repeated prompt text, timestamps, or newest-session order. A fork is
497
+ * therefore allowed only when it received this launch's correlation metadata.
476
498
  */
477
- async function findLaunchSession(nonce: string): Promise<{
499
+ async function findLaunchSession(correlationIdentity: string): Promise<{
478
500
  session: OCSession;
479
501
  knownRootSessionIds: string[];
480
502
  } | null> {
@@ -485,25 +507,17 @@ async function findLaunchSession(nonce: string): Promise<{
485
507
  const knownRootSessionIds = sessions
486
508
  .filter(isTopLevelSession)
487
509
  .map((session) => session.id);
488
- const candidates = await Promise.all(sessions.map(async (session) => {
489
- try {
490
- const messageTime = kickoffMessageTime(
491
- await listSessionMessages(session.id),
492
- nonce,
493
- );
494
- return messageTime === null ? null : { session, messageTime };
495
- } catch {
496
- return null;
497
- }
498
- }));
499
- const matched = candidates.filter(
500
- (candidate): candidate is { session: OCSession; messageTime: number } => candidate !== null,
501
- );
502
- if (matched.length === 0) return null;
503
- const session = matched.reduce((best, candidate) =>
504
- candidate.messageTime > best.messageTime ? candidate : best,
505
- ).session;
506
- return { session, knownRootSessionIds };
510
+ const candidates = await Promise.all(sessions.map(async (session) => ({
511
+ session,
512
+ matchCount: launchCorrelationMatchCount(
513
+ await listSessionMessages(session.id),
514
+ correlationIdentity,
515
+ ),
516
+ })));
517
+ const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
518
+ if (totalMatches !== 1) return null;
519
+ const matched = candidates.find((candidate) => candidate.matchCount === 1);
520
+ return matched ? { session: matched.session, knownRootSessionIds } : null;
507
521
  } catch {
508
522
  return null;
509
523
  }
@@ -615,6 +629,14 @@ async function deliverOpenCodeEntry(
615
629
  ): Promise<OpenCodeDeliveryOutcome> {
616
630
  let target: OCSession | null = null;
617
631
 
632
+ const sourcePending = async (): Promise<boolean> => {
633
+ if (!delivery.isSourcePending) return true;
634
+ if (await delivery.isSourcePending()) return true;
635
+ delivery.settled = true;
636
+ settleOpenCodeEntry(delivery.sourceEntryId);
637
+ return false;
638
+ };
639
+
618
640
  // Before the one allowed POST, retries are safe: no submission has happened.
619
641
  // OpenCode must generate the message ID: its run loop treats IDs as
620
642
  // lexicographically ordered, so arbitrary caller IDs can persist without
@@ -623,6 +645,7 @@ async function deliverOpenCodeEntry(
623
645
  for (let attempt = 0; attempt < OPEN_CODE_DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
624
646
  if (delivery.settled) return 'delivered';
625
647
  if (state !== owner || !owner.connected) return 'failed';
648
+ if (!(await sourcePending())) return 'delivered';
626
649
  if (attempt > 0) {
627
650
  delivery.state = 'retried';
628
651
  owner.totalEntriesRetried++;
@@ -675,6 +698,7 @@ async function deliverOpenCodeEntry(
675
698
  delivery.state = 'delivered-unconfirmed';
676
699
  } else {
677
700
  if (delivery.settled) return 'delivered';
701
+ if (!(await sourcePending())) return 'delivered';
678
702
  owner.pendingSubmissions.set(delivery.entryId, {
679
703
  sourceEntryId: delivery.sourceEntryId,
680
704
  sessionId: target.id,
@@ -795,11 +819,15 @@ async function processOpenCodeDeliveries(owner: OpenCodeDroneState): Promise<voi
795
819
 
796
820
  /**
797
821
  * Wait for the OpenCode HTTP server, then capture the session that received
798
- * this launch's nonce-bearing `--prompt` kickoff. The binding survives the separate
799
- * MCP-child process, which must never fall back to a newest-session heuristic.
822
+ * this launch's metadata-correlated `--prompt` kickoff. The binding survives
823
+ * the separate MCP-child process, which must never fall back to a newest-session heuristic.
800
824
  */
801
825
  export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promise<boolean> {
802
826
  if (!state?.connected) { log('kickoff: not connected'); return false; }
827
+ if (!isOpenCode256BitIdentity(launch.correlationIdentity)) {
828
+ log('kickoff: correlation identity missing or unverifiable');
829
+ return false;
830
+ }
803
831
 
804
832
  try {
805
833
  // Wait for the server.
@@ -815,9 +843,9 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
815
843
  }
816
844
 
817
845
  // Capture the launch-selected session, including explicit resume/fork
818
- // targets. Unrelated sessions do not contain this launch's nonce.
846
+ // targets. Unrelated sessions do not contain this launch's metadata identity.
819
847
  for (let i = 0; i < 30; i++) {
820
- const binding = await findLaunchSession(launch.nonce);
848
+ const binding = await findLaunchSession(launch.correlationIdentity);
821
849
  if (binding) {
822
850
  saveBinding(binding.session, binding.knownRootSessionIds);
823
851
  log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`);
@@ -847,6 +875,7 @@ export function injectOpenCodeEntry(
847
875
  entryId: string = createHash('sha256').update(text).digest('hex'),
848
876
  allowSubmit: boolean = true,
849
877
  sourceEntryId: string = entryId,
878
+ isSourcePending?: () => Promise<boolean>,
850
879
  ): Promise<boolean> {
851
880
  const owner = state;
852
881
  if (!owner?.connected) {
@@ -865,7 +894,7 @@ export function injectOpenCodeEntry(
865
894
  );
866
895
  if (pendingSource) {
867
896
  log(`entry ${entryId} reconciles pending source ${sourceEntryId}`);
868
- return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId);
897
+ return injectOpenCodeEntry(text, pendingSource[0], false, sourceEntryId, isSourcePending);
869
898
  }
870
899
  for (const [deliveredEntryId, record] of owner.deliveredEntries) {
871
900
  if (deliveredEntryId !== entryId && record.sourceEntryId === sourceEntryId) {
@@ -950,6 +979,7 @@ export function injectOpenCodeEntry(
950
979
  acceptedSubmission: false,
951
980
  sessionId: null,
952
981
  settled: false,
982
+ isSourcePending,
953
983
  state: 'queued',
954
984
  resolve: resolveDelivery,
955
985
  promise,
@@ -0,0 +1,46 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ export const OPENCODE_SERVER_USERNAME = 'opencode';
4
+ export const OPENCODE_SERVER_USERNAME_ENV = 'OPENCODE_SERVER_USERNAME';
5
+ export const OPENCODE_SERVER_PASSWORD_ENV = 'OPENCODE_SERVER_PASSWORD';
6
+ export const BORG_OPENCODE_LAUNCH_CORRELATION_ENV = 'BORG_OPENCODE_LAUNCH_CORRELATION';
7
+ export const OPENCODE_SERVER_PASSWORD_REFERENCE = `{env:${OPENCODE_SERVER_PASSWORD_ENV}}`;
8
+
9
+ export interface OpenCodeLaunchTrust {
10
+ apiPassword: string;
11
+ correlationIdentity: string;
12
+ }
13
+
14
+ function random256BitIdentity(): string {
15
+ return randomBytes(32).toString('base64url');
16
+ }
17
+
18
+ export function createOpenCodeLaunchTrust(overrides: Partial<OpenCodeLaunchTrust> = {}): OpenCodeLaunchTrust {
19
+ const trust = {
20
+ apiPassword: overrides.apiPassword ?? random256BitIdentity(),
21
+ correlationIdentity: overrides.correlationIdentity ?? random256BitIdentity(),
22
+ };
23
+ if (!isOpenCode256BitIdentity(trust.apiPassword) || !isOpenCode256BitIdentity(trust.correlationIdentity)) {
24
+ throw new Error('OpenCode launch trust must contain independent 256-bit identities');
25
+ }
26
+ if (trust.apiPassword === trust.correlationIdentity) {
27
+ throw new Error('OpenCode API password and correlation identity must be independent');
28
+ }
29
+ return trust;
30
+ }
31
+
32
+ export function isOpenCode256BitIdentity(value: unknown): value is string {
33
+ if (typeof value !== 'string' || value.length === 0) return false;
34
+ try {
35
+ const decoded = Buffer.from(value, 'base64url');
36
+ return decoded.length === 32 && decoded.toString('base64url') === value;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ export function openCodeApiPasswordFromEnv(env: NodeJS.ProcessEnv): string | null {
43
+ if (env[OPENCODE_SERVER_USERNAME_ENV] !== OPENCODE_SERVER_USERNAME) return null;
44
+ const password = env[OPENCODE_SERVER_PASSWORD_ENV];
45
+ return isOpenCode256BitIdentity(password) ? password : null;
46
+ }
@@ -20,6 +20,7 @@ const COMPACT_FALLBACK =
20
20
  export const OPENCODE_INJECTED_ENTRY_METADATA_KEY = 'borgOpenCodeInjectedEntry';
21
21
  export const OPENCODE_WAKE_IDENTITY_METADATA_KEY = 'borgOpenCodeWakeIdentity';
22
22
  export const OPENCODE_RECOVERY_METADATA_KEY = 'borgOpenCodeSessionOrientation';
23
+ export const OPENCODE_LAUNCH_CORRELATION_METADATA_KEY = 'borgOpenCodeLaunchCorrelation';
23
24
  const PLUGIN_REL_PATH = path.join('.config', 'opencode', 'plugins', 'borg-orient.js');
24
25
 
25
26
  export interface OpenCodePluginCoreDeps {
@@ -45,6 +46,8 @@ export interface OpenCodePluginCoreOptions {
45
46
  confirmationPollAttempts: number;
46
47
  pollDelayMs: number;
47
48
  compactFallback: string;
49
+ launchCorrelationMetadataKey: string;
50
+ launchCorrelationIdentity: string;
48
51
  }
49
52
 
50
53
  /** Pure, dependency-injected behavior core. Its emitted JavaScript function
@@ -55,6 +58,7 @@ export function createOpenCodePluginCore(
55
58
  ) {
56
59
  const claimedSessions = new Set<string>();
57
60
  const humanPromptSessions = new Set<string>();
61
+ let launchCorrelationAttached = false;
58
62
  const textParts = (message: any): any[] => Array.isArray(message?.parts)
59
63
  ? message.parts.filter((part: any) => part?.type === 'text' && typeof part.text === 'string')
60
64
  : [];
@@ -129,7 +133,28 @@ export function createOpenCodePluginCore(
129
133
  output: { message: unknown; parts: any[] },
130
134
  ): Promise<void> => {
131
135
  if (!options.enabled) return;
132
- const current = { info: { role: 'user' }, parts: [...output.parts] };
136
+ let current = { info: { role: 'user' }, parts: [...output.parts] };
137
+ if (
138
+ !launchCorrelationAttached &&
139
+ options.launchCorrelationIdentity &&
140
+ !isInjectedEntry(current) &&
141
+ !isOwnedRecovery(current)
142
+ ) {
143
+ const index = output.parts.findIndex((part) =>
144
+ part?.type === 'text' && typeof part.text === 'string');
145
+ if (index >= 0) {
146
+ const part = output.parts[index];
147
+ output.parts[index] = {
148
+ ...part,
149
+ metadata: {
150
+ ...(part.metadata && typeof part.metadata === 'object' ? part.metadata : {}),
151
+ [options.launchCorrelationMetadataKey]: options.launchCorrelationIdentity,
152
+ },
153
+ };
154
+ launchCorrelationAttached = true;
155
+ current = { info: { role: 'user' }, parts: [...output.parts] };
156
+ }
157
+ }
133
158
  if (!isInjectedEntry(current) && !isOwnedRecovery(current)) {
134
159
  // chat.message fires before the user message is persisted. Record the
135
160
  // human turn synchronously so recovery cannot race that short gap.
@@ -165,6 +190,7 @@ export function buildBorgPluginSource(version: string): string {
165
190
  const createCore = ${createOpenCodePluginCore.toString()};
166
191
  const evaluateAudit = ${evaluateLogAudit.toString()};
167
192
  export default async function (ctx) {
193
+ const launchCorrelationIdentity = process.env.BORG_OPENCODE_LAUNCH_CORRELATION || '';
168
194
  const runRegen = async (source) => {
169
195
  const input = JSON.stringify({ source });
170
196
  const result = await ctx.$\`printf '%s' \${input} | borg-regen\`.quiet().nothrow();
@@ -204,8 +230,12 @@ export default async function (ctx) {
204
230
  confirmationPollAttempts: 6,
205
231
  pollDelayMs: 200,
206
232
  compactFallback: COMPACT_FALLBACK,
233
+ launchCorrelationMetadataKey: OPENCODE_LAUNCH_CORRELATION_METADATA_KEY,
207
234
  })},
208
235
  enabled: process.env.BORG_SESSION === '1',
236
+ launchCorrelationIdentity: /^[A-Za-z0-9_-]{43}$/.test(launchCorrelationIdentity)
237
+ ? launchCorrelationIdentity
238
+ : '',
209
239
  });
210
240
  }
211
241
  `;
@@ -16,6 +16,7 @@ import {
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import {
18
18
  createProtocolEnvelope,
19
+ decodeAppendLogResult,
19
20
  decodeDeleteCubeResponse,
20
21
  decodeDeleteRoleRequest,
21
22
  decodeDeleteRoleResult,
@@ -90,7 +91,7 @@ const UNREAD_CURSOR_MAX_TRANSPORT_RETRIES = 1;
90
91
  // Replay is opt-in: most local requests are mutations or have ambiguous
91
92
  // delivery, while unread-log reads carry an explicit cursor and are safe to
92
93
  // repeat with the exact same request body.
93
- type AuthedFetchRetryMode = 'unread-cursor';
94
+ type AuthedFetchRetryMode = 'unread-cursor' | 'append-log';
94
95
  export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
95
96
  // A typed auth-error envelope is tiny; anything larger is hostile and the
96
97
  // bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
@@ -790,17 +791,17 @@ async function authedFetch(
790
791
  return res;
791
792
  };
792
793
 
793
- let transportRetriesRemaining = retryMode === 'unread-cursor'
794
+ let transportRetriesRemaining = retryMode === 'unread-cursor' || retryMode === 'append-log'
794
795
  ? UNREAD_CURSOR_MAX_TRANSPORT_RETRIES
795
796
  : 0;
796
797
  const requestWithRetry = async (): Promise<Response> => {
797
798
  try {
798
799
  return await buildRequest(token);
799
800
  } catch (error) {
800
- if (retryMode !== 'unread-cursor' || !isConnectionReset(error)) throw error;
801
+ if ((retryMode !== 'unread-cursor' && retryMode !== 'append-log') || !isConnectionReset(error)) throw error;
801
802
  if (transportRetriesRemaining === 0) throw unreadLogTransportFailure(error);
802
803
  transportRetriesRemaining -= 1;
803
- debugLog('↻ retrying unread log read after ECONNRESET');
804
+ debugLog(`↻ retrying ${retryMode === 'append-log' ? 'log append' : 'unread log read'} after ECONNRESET`);
804
805
  try {
805
806
  return await buildRequest(token);
806
807
  } catch (retryError) {
@@ -1334,31 +1335,14 @@ export async function appendLog(
1334
1335
  to?: string[];
1335
1336
  serverTrustIdentity?: string;
1336
1337
  } = {}
1337
- ): Promise<{
1338
- entry: {
1339
- id: string;
1340
- cube_id: string;
1341
- drone_id: string;
1342
- message: string;
1343
- visibility: 'broadcast' | 'direct';
1344
- created_at: string;
1345
- };
1346
- routing?: {
1347
- class: string | null;
1348
- recipients: string[];
1349
- fellOpen: boolean;
1350
- message: string | null;
1351
- } | null;
1352
- // gh#534: directed recipients currently unreachable via the wake path
1353
- // (wake-path:deaf). Empty/absent for broadcast or all-reachable sends.
1354
- unreachableRecipients?: { id: string; label: string }[];
1355
- }> {
1338
+ ): Promise<ReturnType<typeof decodeAppendLogResult>> {
1356
1339
  if (opts.visibility === 'broadcast' && (opts.to?.length ?? 0) > 0) {
1357
1340
  throw new Error(
1358
1341
  "Invalid input: visibility:'broadcast' cannot be combined with non-empty to:. " +
1359
1342
  'Remove visibility to direct to recipients, or remove to: to broadcast.',
1360
1343
  );
1361
1344
  }
1345
+ const postId = randomUUID();
1362
1346
  const local = await localAuthorityContext(
1363
1347
  sessionToken,
1364
1348
  apiUrl,
@@ -1386,11 +1370,12 @@ export async function appendLog(
1386
1370
  } else if (visibility === undefined && recipientDroneIds !== undefined) {
1387
1371
  visibility = 'direct';
1388
1372
  }
1389
- const payload = await localServerRequest<{ entry: any }>(
1373
+ const payload = await localServerRequest<ReturnType<typeof decodeAppendLogResult>>(
1390
1374
  local,
1391
1375
  `/api/cubes/${local.cubeId}/logs`,
1392
1376
  'POST',
1393
1377
  {
1378
+ post_id: postId,
1394
1379
  message,
1395
1380
  ...(visibility ? { visibility } : {}),
1396
1381
  ...(visibility === 'direct' && recipientDroneIds
@@ -1401,9 +1386,10 @@ export async function appendLog(
1401
1386
  // visibility/recipients override it (server resolveMessageRouting).
1402
1387
  ...(opts.class ? { class: opts.class } : {}),
1403
1388
  },
1389
+ { retryMode: 'append-log', decodePayload: decodeAppendLogResult },
1404
1390
  );
1405
1391
  if (!payload) throw new Error('Local Borg server returned an empty log response');
1406
- return { entry: payload.entry };
1392
+ return payload;
1407
1393
  }
1408
1394
 
1409
1395
  /**
@@ -211,10 +211,11 @@ export interface ServerAttachResult {
211
211
  sessionId: string;
212
212
  };
213
213
  result: 'created' | 'reused';
214
+ initial_log_cursor: { id: string; created_at: string } | null;
214
215
  }
215
216
 
216
217
  /**
217
- * Attach an enrolled client principal to one granted cube/role over protocol v8.
218
+ * Attach an enrolled client principal to one granted cube/role over protocol v9.
218
219
  * The client CSPRNG-generates the session bearer and persists it PENDING in the
219
220
  * OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
220
221
  * interrupted/lost response is recovered by re-sending the exact same bearer —
@@ -237,6 +238,7 @@ export interface PreparedServerAttach {
237
238
  drone: ServerAttachResult['drone'];
238
239
  session: { sessionId: string };
239
240
  result: 'created' | 'reused';
241
+ initialLogCursor: ServerAttachResult['initial_log_cursor'];
240
242
  credentialRef: string;
241
243
  pendingBearerDigest: string;
242
244
  /** The single-store ATOMIC activate+bind (CR#2 collapse): given the worktree
@@ -391,6 +393,7 @@ export async function sendBorgServerAttach(
391
393
  sessionId: decoded.session.id,
392
394
  },
393
395
  result: decoded.result,
396
+ initialLogCursor: decoded.initial_log_cursor,
394
397
  credentialRef,
395
398
  pendingBearerDigest,
396
399
  // The single-store ATOMIC activate+bind — invoked by FINALIZE with the decided
@@ -28,7 +28,7 @@ export interface ConfigMutationTarget {
28
28
  * agent CLIs. Paths mirror `config-utils.ts`:
29
29
  * Claude Code: ~/.claude.json (MCP server) + ~/.claude/settings.json (hook)
30
30
  * Codex: ~/.codex/config.toml (MCP server) + ~/.codex/hooks.json (hooks)
31
- * OpenCode: ~/.config/opencode/opencode.json (MCP server)
31
+ * OpenCode: effective global config under ~/.config/opencode/ (MCP server)
32
32
  */
33
33
  export function configMutationTargets(deps: {
34
34
  claude: boolean;
@@ -58,8 +58,8 @@ export function configMutationTargets(deps: {
58
58
  }
59
59
  if (deps.opencode) {
60
60
  targets.push({
61
- file: '~/.config/opencode/opencode.json',
62
- change: 'registers the borg MCP server (with BORG_SESSION activation)',
61
+ file: '~/.config/opencode/',
62
+ change: 'updates the effective global OpenCode configuration to register the borg MCP server (with BORG_SESSION activation)',
63
63
  });
64
64
  }
65
65
  return targets;