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
@@ -0,0 +1,40 @@
1
+ /** Redaction and fail-closed credential detection for untrusted Git remotes. */
2
+
3
+ const URL_USERINFO_RE = /([a-z][a-z0-9+.-]*:\/\/)([^/\s@]+)@/gi;
4
+ const URL_SUFFIX_RE = /([a-z][a-z0-9+.-]*:\/\/[^\s?#)]+)([?#])[^\s)]*/gi;
5
+ const NAMED_SECRET_RE = /([?&](?:access[_-]?token|api[_-]?key|auth(?:entication)?|credential|password|passwd|private[_-]?key|secret|token)=)[^&#\s)]+/gi;
6
+ const NAMED_SECRET_TEST_RE = new RegExp(NAMED_SECRET_RE.source, 'i');
7
+ const OPTION_SECRET_RE = /((?:^|[\s("'`])--(?:access[_-]?token|api[_-]?key|auth(?:entication)?|credential|password|passwd|private[_-]?key|secret|token)=)[^\s)]*/gi;
8
+ const SCP_REMOTE_RE = /^[^@\s/:]+@[^:\s]+:[^\s]+$/;
9
+
10
+ export function redactCloneSecrets(value: string): string {
11
+ return value
12
+ .replace(URL_USERINFO_RE, '$1<credentials>@')
13
+ .replace(URL_SUFFIX_RE, '$1$2<redacted>')
14
+ .replace(NAMED_SECRET_RE, '$1<redacted>')
15
+ .replace(OPTION_SECRET_RE, '$1<redacted>')
16
+ .replace(/(^|[\s("'`])[^/\s:@]+:[^@\s/]+@(?=[^\s/])/g, '$1<credentials>@');
17
+ }
18
+
19
+ /**
20
+ * True means the value must never reach subprocess argv, output, or Git config.
21
+ * Opaque `scheme:...@...` forms fail closed; URL() accepts those but exposes no
22
+ * username/password fields, which is precisely the class that escaped the old flow.
23
+ */
24
+ export function hasCloneCredentials(value: string): boolean {
25
+ if (NAMED_SECRET_TEST_RE.test(value)) return true;
26
+ if (SCP_REMOTE_RE.test(value)) return false;
27
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1].toLowerCase();
28
+ if (scheme && !value.toLowerCase().startsWith(`${scheme}://`) && scheme !== 'file') {
29
+ return value.includes('@');
30
+ }
31
+ try {
32
+ const parsed = new URL(value);
33
+ if (parsed.protocol === 'ssh:' || parsed.protocol === 'git+ssh:') {
34
+ return parsed.password.length > 0;
35
+ }
36
+ return parsed.username.length > 0 || parsed.password.length > 0;
37
+ } catch {
38
+ return /[^/\s:@]+:[^@\s/]+@/.test(value);
39
+ }
40
+ }
package/src/cubes.ts CHANGED
@@ -91,8 +91,8 @@ export class LaunchSeatIdentityChangedError extends Error {
91
91
 
92
92
  constructor(droneLabel: string) {
93
93
  super(
94
- `borg launch: did not launch '${droneLabel}' — its seat registration changed before the launch could start. ` +
95
- 'Run `borg seats` to see the current state, then try again.',
94
+ `borg launch: did not launch '${droneLabel}' — its registration changed before the launch could start. ` +
95
+ 'Run `borg drones` to see the current state, then try again.',
96
96
  );
97
97
  this.name = 'LaunchSeatIdentityChangedError';
98
98
  }
package/src/index.ts CHANGED
@@ -133,6 +133,7 @@ import { resolveReportableSessionAgentKind } from './agent-runtime.js';
133
133
  import {
134
134
  connectOpenCodeDrone,
135
135
  injectOpenCodeEntry,
136
+ settleOpenCodeEntry,
136
137
  configuredOpenCodePort,
137
138
  OPEN_CODE_PORT_MISSING_DIAGNOSTIC,
138
139
  openCodeLaunchBinding,
@@ -314,7 +315,9 @@ export async function main() {
314
315
  installBorgPlugin();
315
316
  const active = await getActiveCube();
316
317
  if (active && openCodeRuntime) {
317
- if (await connectOpenCodeRuntime(active)) setModuleInjectOpenCode(injectOpenCodeEntry);
318
+ if (await connectOpenCodeRuntime(active)) {
319
+ setModuleInjectOpenCode(injectOpenCodeEntry, settleOpenCodeEntry);
320
+ }
318
321
  }
319
322
  },
320
323
  };
@@ -16,6 +16,9 @@ import { runPastelistBackend } from './backends/launch-all-pastelist.js';
16
16
 
17
17
  type Backend = 'tmux' | 'terminals' | 'pastelist';
18
18
 
19
+ /** Internal strict-seam result: requested sessions were not dispatched. */
20
+ export const LAUNCH_ALL_NO_DISPATCH_EXIT_CODE = 2;
21
+
19
22
  const TMUX_INSTALL_HINT =
20
23
  'borg launch-all: tmux not found.\n' +
21
24
  ' macOS: brew install tmux\n' +
@@ -239,6 +242,12 @@ export interface RunLaunchAllOptions {
239
242
  sleep?: (ms: number) => Promise<void>;
240
243
  nowISO?: () => string;
241
244
  borgPath?: string;
245
+ /** Internal quickstart filter: launch only the requested staffed roster. */
246
+ droneIds?: readonly string[];
247
+ /** Fail when any requested registered drone cannot be discovered or launched. */
248
+ requireAllRequested?: boolean;
249
+ /** Internal resolved target avoids ambiguous same-name lookup during composition. */
250
+ targetCube?: { cubeId: string; name: string };
242
251
  }
243
252
 
244
253
  export async function runLaunchAll(
@@ -253,7 +262,7 @@ export async function runLaunchAll(
253
262
  const launchDelayMs = resolveLaunchDelayMs(args.flags.launchDelayMs, deps.getEnv('BORG_LAUNCH_DELAY_MS'));
254
263
 
255
264
  // 1. resolve target cube
256
- const resolved = await resolveTargetCube(args, deps);
265
+ const resolved = opts.targetCube ?? await resolveTargetCube(args, deps);
257
266
  if ('error' in resolved) {
258
267
  deps.stderr(`borg launch-all: ${resolved.error}\n`);
259
268
  return 1;
@@ -264,7 +273,18 @@ export async function runLaunchAll(
264
273
  sweepStaleLocks(deps, cubeId, now());
265
274
 
266
275
  // 3. discover candidates
267
- const discovered = await discoverDroneCandidates({ targetCubeId: cubeId, only: args.flags.only }, deps);
276
+ let discovered = await discoverDroneCandidates({ targetCubeId: cubeId, only: args.flags.only }, deps);
277
+ if (opts.droneIds !== undefined) {
278
+ const requested = new Set(opts.droneIds);
279
+ discovered = discovered.filter((candidate) => requested.has(candidate.droneId));
280
+ if (opts.requireAllRequested && discovered.length !== requested.size) {
281
+ deps.stderr(
282
+ `borg launch-all: found ${discovered.length} of ${requested.size} requested registered drone worktrees; ` +
283
+ 'nothing was launched. Run `borg drones` to inspect the missing local registration.\n',
284
+ );
285
+ return 1;
286
+ }
287
+ }
268
288
  if (discovered.length === 0) {
269
289
  if (args.flags.only !== undefined) {
270
290
  deps.stdout(`No worktrees matched --only '${args.flags.only}' for cube '${cubeName}'\n`);
@@ -392,6 +412,14 @@ export async function runLaunchAll(
392
412
  }
393
413
  launchable.push(c);
394
414
  }
415
+ const terminalSkipCount = evictedCount + revokedCount + rejectedCount + trustMismatchCount + credentialRejectedCount;
416
+ if (opts.requireAllRequested && terminalSkipCount > 0) {
417
+ deps.stderr(
418
+ `borg launch-all: ${terminalSkipCount} requested registered drone(s) are not launchable; ` +
419
+ 'nothing was launched. Resolve the cause reported above, then retry.\n',
420
+ );
421
+ return 1;
422
+ }
395
423
  if (launchable.length === 0) {
396
424
  // Accurate cause counts — an all-rejected sweep must NOT claim "evicted"
397
425
  // (and vice-versa). Only name a cause when at least one seat hit it.
@@ -441,6 +469,13 @@ export async function runLaunchAll(
441
469
  deps.stderr(sel.hardFail);
442
470
  return 1;
443
471
  }
472
+ if (sel.backend === 'pastelist' && opts.requireAllRequested) {
473
+ deps.stderr(
474
+ 'borg launch-all: pastelist mode prints commands for manual use but does not launch sessions; ' +
475
+ 'nothing was launched. Run `borg launch-all` separately to print the commands.\n',
476
+ );
477
+ return LAUNCH_ALL_NO_DISPATCH_EXIT_CODE;
478
+ }
444
479
  const sessionName = sanitizeSessionName(cubeName);
445
480
  const launchStartISO = nowISO();
446
481
 
package/src/log-stream.ts CHANGED
@@ -59,6 +59,7 @@ import { formatCubeActivityWakeMessage } from './cube-activity-wake-copy.js';
59
59
  import { readBoundedResponseBody } from './server-response.js';
60
60
  import { BorgServerError } from './server-errors.js';
61
61
  import { markSeatRejected } from './seats.js';
62
+ import { hasPendingWakeEntry as hasPendingDurableWakeEntry } from './remote-client.js';
62
63
  import {
63
64
  acquireStreamLease,
64
65
  readOwnershipSnapshot,
@@ -124,13 +125,16 @@ function isProcessAlive(pid: number): boolean {
124
125
  * Used by defaultDeps when no explicit injectOpenCode is supplied.
125
126
  */
126
127
  let _moduleInjectOpenCode:
127
- | ((text: string, entryId: string, allowSubmit: boolean) => Promise<boolean>)
128
+ | ((text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string) => Promise<boolean>)
128
129
  | undefined;
130
+ let _moduleSettleOpenCodeEntry: ((sourceEntryId: string) => void) | undefined;
129
131
 
130
132
  export function setModuleInjectOpenCode(
131
- fn: (text: string, entryId: string, allowSubmit: boolean) => Promise<boolean>
133
+ fn: (text: string, entryId: string, allowSubmit: boolean, sourceEntryId?: string) => Promise<boolean>,
134
+ settle: (sourceEntryId: string) => void,
132
135
  ): void {
133
136
  _moduleInjectOpenCode = fn;
137
+ _moduleSettleOpenCodeEntry = settle;
134
138
  }
135
139
 
136
140
  /**
@@ -403,8 +407,13 @@ export interface StreamDeps {
403
407
  injectOpenCode?: (
404
408
  text: string,
405
409
  entryId: string,
406
- allowSubmit: boolean
410
+ allowSubmit: boolean,
411
+ sourceEntryId?: string,
407
412
  ) => Promise<boolean>;
413
+ /** Inspect whether one retry source remains beyond the durable unread cursor. */
414
+ hasPendingWakeEntry?: (active: ActiveCube, entryId: string) => Promise<boolean>;
415
+ /** Clear in-process retry diagnostics after the durable entry is consumed. */
416
+ settleOpenCodeEntry?: (sourceEntryId: string) => void;
408
417
  }
409
418
 
410
419
  const defaultDeps: Required<StreamDeps> = {
@@ -420,10 +429,15 @@ const defaultDeps: Required<StreamDeps> = {
420
429
  abortSignal: new AbortController().signal,
421
430
  ownerDeps: {},
422
431
  ownerStaleMs: 70_000,
423
- injectOpenCode: (text, entryId, allowSubmit) =>
432
+ injectOpenCode: (text, entryId, allowSubmit, sourceEntryId) =>
424
433
  _moduleInjectOpenCode
425
- ? _moduleInjectOpenCode(text, entryId, allowSubmit)
434
+ ? _moduleInjectOpenCode(text, entryId, allowSubmit, sourceEntryId)
426
435
  : Promise.resolve(false),
436
+ hasPendingWakeEntry: (active, entryId) => hasPendingDurableWakeEntry(
437
+ active as Parameters<typeof hasPendingDurableWakeEntry>[0],
438
+ entryId,
439
+ ),
440
+ settleOpenCodeEntry: (sourceEntryId) => _moduleSettleOpenCodeEntry?.(sourceEntryId),
427
441
  };
428
442
 
429
443
  // ------------------------------------------------------------------
@@ -678,6 +692,8 @@ export async function streamOnce(
678
692
  hwmDivergenceGraceMs,
679
693
  abortSignal,
680
694
  injectOpenCode,
695
+ hasPendingWakeEntry,
696
+ settleOpenCodeEntry,
681
697
  } = { ...defaultDeps, ...deps };
682
698
 
683
699
  assertUuidShape(active.cubeId, 'cube_id');
@@ -819,6 +835,17 @@ export async function streamOnce(
819
835
  pendingHwmDivergence = { hwm, timer };
820
836
  };
821
837
 
838
+ const shouldDeliverWakeRetry = async (entryId: string): Promise<boolean> => {
839
+ try {
840
+ if (await hasPendingWakeEntry(active, entryId)) return true;
841
+ settleOpenCodeEntry(entryId);
842
+ } catch {
843
+ // A retry is never authoritative enough to cross the model boundary on
844
+ // its own. The durable inbox and a later retry remain available.
845
+ }
846
+ return false;
847
+ };
848
+
822
849
  // Bounded recent-id set for replay-on-reconnect dedup per spec §(3).
823
850
  // Set + FIFO array for O(1) membership + bounded memory.
824
851
  const recentIds = new Set<string>();
@@ -844,6 +871,10 @@ export async function streamOnce(
844
871
  const line = formatInboxLine(withSseEventId(ev.data, ev.id));
845
872
  const deliveryId = ev.wake_nonce ?? ev.id;
846
873
  const isReping = ev.wake_nonce !== undefined;
874
+ if (isReping && !(await shouldDeliverWakeRetry(ev.id))) {
875
+ markEventPersisted(ev.id, ev.data?.created_at ?? '');
876
+ return 'persisted-skip';
877
+ }
847
878
  const eventHwm = ev.data?.created_at
848
879
  ? { id: ev.id, created_at: ev.data.created_at }
849
880
  : null;
@@ -867,12 +898,11 @@ export async function streamOnce(
867
898
  await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line)
868
899
  );
869
900
  if (alreadyPersisted) {
870
- // An ordinary entry at/before the persisted cursor is already delivered
871
- // and must not re-enter the OpenCode queue. Re-pings are the explicit
872
- // exception: each wake nonce is a new delivery identity even when the
873
- // underlying inbox line is already durable.
901
+ // An ordinary entry at/before the persisted cursor must not re-enter the
902
+ // OpenCode queue. A still-unread re-ping may reconcile the prior attempt;
903
+ // its source entry ID prevents a new nonce from resubmitting that prompt.
874
904
  if (isReping) {
875
- await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true);
905
+ await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true, ev.id);
876
906
  }
877
907
  markEventPersisted(ev.id, ev.data?.created_at ?? '');
878
908
  return 'persisted-skip';
@@ -880,7 +910,10 @@ export async function streamOnce(
880
910
  // The inbox append is the durable record. OpenCode injection is only the
881
911
  // wake attempt and may return before the agent finishes processing.
882
912
  await appendLine(active.cubeId, active.droneId, line);
883
- if (!(await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true))) {
913
+ const openCodeDelivered = isReping
914
+ ? await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true, ev.id)
915
+ : await injectOpenCode(formatOpenCodeWakePrompt(line), deliveryId, true);
916
+ if (!openCodeDelivered) {
884
917
  if (ev.wake_nonce === undefined) wakeCodex(formatCodexWakePrompt(line));
885
918
  else wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce);
886
919
  }
@@ -1198,7 +1231,11 @@ export async function streamOnce(
1198
1231
  if (isRecentWakeReplay) {
1199
1232
  const line = formatInboxLine(withSseEventId(event.data, event.id));
1200
1233
  const wakeNonce = event.wake_nonce;
1201
- if (wakeNonce !== undefined && !(await injectOpenCode(formatOpenCodeWakePrompt(line), wakeNonce, true))) {
1234
+ if (
1235
+ wakeNonce !== undefined &&
1236
+ await shouldDeliverWakeRetry(event.id) &&
1237
+ !(await injectOpenCode(formatOpenCodeWakePrompt(line), wakeNonce, true, event.id))
1238
+ ) {
1202
1239
  wakeCodex(formatCodexWakePrompt(line), wakeNonce);
1203
1240
  }
1204
1241
  continue;