borgmcp 4.10.0 → 5.0.1

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.
@@ -276,23 +276,22 @@ function diagnoseSessionTermination(deps, apiUrl, outcome, mode = 'assimilate')
276
276
  `Next: run borg reset-local-connection, then ${recovery}.\n`);
277
277
  return 1;
278
278
  }
279
- export async function runAssimilate(args, deps, options = {}) {
279
+ const continueAssimilation = (value) => ({
280
+ kind: 'continue',
281
+ value,
282
+ });
283
+ export async function resolveAssimilationRepository(args, deps) {
280
284
  const mode = args.mode ?? 'assimilate';
281
- // ----- Input validation (before any subprocess work) -----
282
- // A role is a lookup key, not a path component. matchRoleByName() below
283
- // applies the shared roleSlug() normalization, so displayed names such as
284
- // "Builder" and "Code Reviewer" must reach that resolver. Keep the strict
285
- // identifier validator for worktree names, which do become path components.
286
285
  if (args.flags.worktree !== undefined) {
287
- const v = validateName(args.flags.worktree);
288
- if (!v.ok) {
289
- deps.stderr(v.error + '\n');
290
- return 1;
286
+ const validation = validateName(args.flags.worktree);
287
+ if (!validation.ok) {
288
+ deps.stderr(validation.error + '\n');
289
+ return { kind: 'stop', code: 1 };
291
290
  }
292
291
  }
293
292
  if (args.flags.cubeName !== undefined && !validRepositoryCubeName(args.flags.cubeName.trim())) {
294
293
  deps.stderr('Invalid cube name. Use 1-120 letters, digits, spaces, dots, underscores, or hyphens, starting with a letter or digit.\n');
295
- return 1;
294
+ return { kind: 'stop', code: 1 };
296
295
  }
297
296
  let repositoryContext;
298
297
  try {
@@ -302,18 +301,449 @@ export async function runAssimilate(args, deps, options = {}) {
302
301
  if (error instanceof Error && error.message === 'BARE_REPOSITORY') {
303
302
  const command = mode === 'cube-init' ? 'borg server cube init' : 'borg assimilate';
304
303
  deps.stderr(`${command} requires a non-bare repository worktree. Clone or check out the repository, then retry.\n`);
305
- return 1;
304
+ return { kind: 'stop', code: 1 };
306
305
  }
307
306
  deps.stderr(`Could not inspect this Git repository: ${repositoryDiscoveryFailureMessage(error)}\n` +
308
307
  'Nothing was changed.\n');
309
- return 1;
308
+ return { kind: 'stop', code: 1 };
310
309
  }
311
310
  if (!repositoryContext) {
312
311
  deps.stderr('No Git repository was found for this directory.\n' +
313
312
  'Nothing was changed.\n' +
314
313
  'Run this command inside a Git repository.\n');
315
- return 1;
314
+ return { kind: 'stop', code: 1 };
315
+ }
316
+ return continueAssimilation({ mode, repositoryContext });
317
+ }
318
+ export async function finalizeAssimilationSeat(input, deps) {
319
+ const { activeCube, apiUrl, repositoryContext, result, sessionExpected, rollbackWorktree } = input;
320
+ if (result.finalize === undefined || deps.finalizeServerSeat === undefined) {
321
+ deps.stderr('Local Borg server session metadata is incomplete; no connection was saved.\n');
322
+ rollbackWorktree();
323
+ return { kind: 'stop', code: 1 };
324
+ }
325
+ let outcome;
326
+ try {
327
+ outcome = await deps.finalizeServerSeat({
328
+ active: activeCube,
329
+ commonDir: repositoryContext.commonDir,
330
+ ...(repositoryContext.publicRepository
331
+ ? { repositoryOrigin: repositoryContext.publicRepository.value }
332
+ : {}),
333
+ expected: sessionExpected,
334
+ activate: result.finalize.activate,
335
+ scrubPending: result.finalize.scrubPending,
336
+ });
337
+ }
338
+ catch (error) {
339
+ const message = error instanceof Error ? error.message : String(error);
340
+ deps.stderr(`finalizeServerSeat failed: ${message}\n`);
341
+ rollbackWorktree();
342
+ return { kind: 'stop', code: 1 };
343
+ }
344
+ if (outcome.committed)
345
+ return continueAssimilation(undefined);
346
+ if (outcome.reason === 'activation-failed') {
347
+ let bindOutcome = 'unavailable';
348
+ if (result.finalize.bindPending) {
349
+ try {
350
+ bindOutcome = (await result.finalize.bindPending({
351
+ worktree: deps.findProjectRoot(deps.cwd()),
352
+ name: activeCube.name,
353
+ droneLabel: activeCube.droneLabel,
354
+ ...(activeCube.roleName !== undefined ? { roleName: activeCube.roleName } : {}),
355
+ ...(activeCube.roleClass !== undefined ? { roleClass: activeCube.roleClass } : {}),
356
+ ...(activeCube.isHumanSeat !== undefined ? { isHumanSeat: activeCube.isHumanSeat } : {}),
357
+ }));
358
+ }
359
+ catch {
360
+ bindOutcome = 'threw';
361
+ }
362
+ }
363
+ if (bindOutcome === 'bound') {
364
+ deps.stderr(`This worktree's secure session on ${apiUrl} did not finish activating, but ` +
365
+ 'its resumable connection state was PRESERVED here. This worktree was NOT removed. From ' +
366
+ `here, re-run ${localAssimilateCommand(apiUrl)} to converge (the identical connection ` +
367
+ `is reused — no duplicate is minted), or run ${resetLocalSeatCommand(apiUrl)} to ` +
368
+ 'clear it.\n');
369
+ return { kind: 'stop', code: 1 };
370
+ }
371
+ const bindFailure = bindOutcome === 'missing'
372
+ ? 'the exact pending connection record went missing locally before it could be bound'
373
+ : bindOutcome === 'replaced'
374
+ ? 'the exact pending connection record was replaced locally before it could be bound; the replacement was left untouched'
375
+ : bindOutcome === 'threw'
376
+ ? 'the private store could not be read or written while preserving the pending connection'
377
+ : 'this client did not receive a pending-connection preservation handle';
378
+ deps.stderr(`This worktree's secure session on ${apiUrl} did not finish activating: ` +
379
+ `${bindFailure}. The spawned worktree will be removed. No client-only command can ` +
380
+ 'prove reuse or safely clear the possibly accepted server-side drone; ask the server ' +
381
+ 'operator to inspect that drone before retrying.\n');
382
+ rollbackWorktree();
383
+ return { kind: 'stop', code: 1 };
384
+ }
385
+ deps.stderr(`This worktree's saved connection to ${apiUrl} changed during attach ` +
386
+ '(a concurrent reset or enroll); no drone was created and nothing was overwritten. ' +
387
+ `Re-run ${localAssimilateCommand(apiUrl)} to attach against the current state.\n`);
388
+ rollbackWorktree();
389
+ return { kind: 'stop', code: 1 };
390
+ }
391
+ export async function launchAssimilatedAgent(input, deps) {
392
+ const { flags, result, cubeDetail, assignedRole, apiUrl, cli, effectiveModel, agentCwd, seatWorktree, scratchRoot, launchAccessPaths, monitorStateRoot, spawnedWorktreePath, originalCwd, } = input;
393
+ deps.setTerminalTitle(result.drone_label, cubeDetail.name);
394
+ const useColor = deps.isTTY() && !process.env.NO_COLOR && !process.env.CI;
395
+ deps.stdout(renderAssimilationWelcome(result.drone_label, assignedRole.name, cubeDetail.name, useColor, apiUrl));
396
+ if (!await deps.probeMcpReady()) {
397
+ deps.stderr(`warning: borg-mcp readiness probe did not complete within the timeout; ` +
398
+ `launching ${cli} anyway — the kickoff prompt's ToolSearch fallback ` +
399
+ `will recover if the MCP server takes longer to start.\n`);
400
+ }
401
+ const inboxPath = deps.getInboxPath(result.cube_id, result.drone_id);
402
+ const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
403
+ const monitorClause = buildKickoffWakePathClause(cli, cli === 'claude' ? inboxPath : null, cli === 'claude' ? monitorStateRoot : null);
404
+ let codexWakePathClause;
405
+ let remoteArgs = [];
406
+ let launchArgs;
407
+ let codexSocketPath = null;
408
+ let codexServerCleanup = null;
409
+ const launchApproval = deps.resolveCliApprovals
410
+ ? await deps.resolveCliApprovals(cli, agentCwd, { skipOverride: flags.noBorgApprovalOverride })
411
+ : { codexArgs: [] };
412
+ if (launchApproval.warning)
413
+ deps.stderr(`warning: ${launchApproval.warning}\n`);
414
+ const modelEnv = resolveLaunchEnv(effectiveModel);
415
+ const childEnv = {
416
+ ...withAgentRuntimeEnv(process.env, cli),
417
+ ...modelEnv.set,
418
+ BORG_SESSION: '1',
419
+ [BORG_LAUNCH_CLI_ENV]: cli,
420
+ [BORG_LAUNCH_WORKTREE_ENV]: seatWorktree,
421
+ [BORG_LAUNCH_SCRATCH_ENV]: scratchRoot,
422
+ };
423
+ if (cli === 'opencode' && launchApproval.openCodePermission) {
424
+ childEnv.OPENCODE_PERMISSION = launchApproval.openCodePermission;
425
+ }
426
+ for (const key of modelEnv.unset)
427
+ delete childEnv[key];
428
+ if (cli === 'codex') {
429
+ const remote = await deps.prepareCodexRemoteLaunch();
430
+ if (remote.warning) {
431
+ deps.stderr(`warning: ${remote.warning}\n`);
432
+ codexWakePathClause =
433
+ '⚠ Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg_regen manually whenever you return, and expect only fallback wakeups until relaunch.';
434
+ }
435
+ else {
436
+ codexWakePathClause = 'Codex wake-path capability check passed: remote-control socket established for this session.';
437
+ }
438
+ remoteArgs = remote.args;
439
+ if (Object.keys(remote.env).length > 0)
440
+ Object.assign(childEnv, remote.env);
441
+ codexSocketPath = socketPathFromRemoteArgs(remote.args);
442
+ codexServerCleanup = remote.server?.cleanup ?? null;
443
+ }
444
+ const kickoff = buildAgentKickoffPrompt({
445
+ cli,
446
+ codexWakeNonce,
447
+ monitorClause,
448
+ codexWakePathClause,
449
+ });
450
+ let openCodeKickoff = null;
451
+ let dronePort;
452
+ launchArgs = [kickoff];
453
+ if (cli === 'codex') {
454
+ launchArgs = [
455
+ ...codexLaunchDirectoryArgs(launchAccessPaths),
456
+ ...launchApproval.codexArgs,
457
+ ...codexBorgSessionConfigArgs(),
458
+ ...codexAgentKindConfigArgs(),
459
+ ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
460
+ ...codexStateRootConfigArgs(),
461
+ ...remoteArgs,
462
+ ...withCodexCwdArg(launchArgs, agentCwd),
463
+ ];
464
+ }
465
+ else if (cli === 'opencode') {
466
+ dronePort = await allocateOpenCodePort();
467
+ childEnv.BORG_OPENCODE_PORT = String(dronePort);
468
+ installBorgPlugin();
469
+ openCodeKickoff = createOpenCodeLaunchKickoff(kickoff);
470
+ childEnv[OPENCODE_SERVER_USERNAME_ENV] = OPENCODE_SERVER_USERNAME;
471
+ childEnv[OPENCODE_SERVER_PASSWORD_ENV] = openCodeKickoff.apiPassword;
472
+ childEnv[BORG_OPENCODE_LAUNCH_CORRELATION_ENV] = openCodeKickoff.correlationIdentity;
473
+ launchArgs = buildOpenCodeLaunchArgs(agentCwd, dronePort, openCodeKickoff.prompt);
474
+ }
475
+ const exitPromise = deps.exec(cli, launchArgs, agentCwd, childEnv);
476
+ if (cli === 'codex' && codexSocketPath && codexWakeNonce) {
477
+ void recordCodexWakeTarget({
478
+ deps,
479
+ cubeId: result.cube_id,
480
+ droneId: result.drone_id,
481
+ socketPath: codexSocketPath,
482
+ cwd: agentCwd,
483
+ previewNeedle: codexWakeNonce,
484
+ launchedAtSeconds: Math.floor(Date.now() / 1000),
485
+ });
486
+ }
487
+ if (cli === 'opencode' && openCodeKickoff) {
488
+ const launchKickoff = openCodeKickoff;
489
+ connectOpenCodeDrone({
490
+ serverUrl: `http://127.0.0.1:${dronePort}`,
491
+ apiPassword: launchKickoff.apiPassword,
492
+ directory: agentCwd,
493
+ droneLabel: result.drone_label,
494
+ cubeName: cubeDetail.name,
495
+ launchIdentity: launchKickoff.correlationIdentity,
496
+ }).then(() => injectInitialKickoff(launchKickoff)).catch(() => { });
497
+ }
498
+ const exitCode = await exitPromise;
499
+ if (codexServerCleanup) {
500
+ try {
501
+ codexServerCleanup();
502
+ }
503
+ catch {
504
+ // Best-effort cleanup after a normal Codex exit.
505
+ }
316
506
  }
507
+ if (spawnedWorktreePath && originalCwd !== spawnedWorktreePath) {
508
+ deps.stderr(`\nAgent exited. You were working in ${spawnedWorktreePath}; your shell is back in ${originalCwd}.\n` +
509
+ 'To return:\n' +
510
+ ` cd ${shellEscape(spawnedWorktreePath)}\n`);
511
+ }
512
+ return exitCode;
513
+ }
514
+ export async function resolveAssimilationCubeRole(input, deps) {
515
+ const { requestedRole, flags, cubeDetail, isFirstDrone, savedLocalRole, apiUrl } = input;
516
+ let resolvedRole;
517
+ if (savedLocalRole) {
518
+ resolvedRole = savedLocalRole;
519
+ }
520
+ else if (requestedRole !== undefined) {
521
+ resolvedRole = matchRoleByName(cubeDetail.roles, requestedRole);
522
+ if (!resolvedRole) {
523
+ const available = cubeDetail.roles.map((role) => role.name).join(', ');
524
+ const suggestion = suggestRoleName(requestedRole, cubeDetail.roles.map((role) => role.name));
525
+ const suggestionLine = suggestion ? ` Did you mean "${suggestion}"?` : '';
526
+ deps.stderr(`No role matching "${requestedRole}" in cube "${cubeDetail.name}" on ${apiUrl}. ` +
527
+ `Available: ${available}.${suggestionLine}\n` +
528
+ `Rerun ${localAssimilateRoleCommand(apiUrl)} with one of the available roles.\n`);
529
+ return { kind: 'stop', code: 1 };
530
+ }
531
+ }
532
+ else {
533
+ const occupiedRoleIds = occupiedRoleIdsForAutoRole(cubeDetail.drones ?? []);
534
+ resolvedRole = pickDefaultRole(cubeDetail.roles, { isFirstDrone, occupiedRoleIds });
535
+ if (!resolvedRole) {
536
+ deps.stderr(`Cube "${cubeDetail.name}" on ${apiUrl} has no default or human-seat role. ` +
537
+ `Ask the server operator to configure a role, then rerun ` +
538
+ `${localAssimilateRoleCommand(apiUrl)}.\n`);
539
+ return { kind: 'stop', code: 1 };
540
+ }
541
+ }
542
+ const effectiveModel = flags.model ?? null;
543
+ const cli = await deps.resolveCli(flags.cli);
544
+ try {
545
+ ensureCliMcpConfigured(cli);
546
+ }
547
+ catch (error) {
548
+ const message = error instanceof Error ? error.message : String(error);
549
+ deps.stderr(`${cli} MCP configuration failed for ${apiUrl}: ${safeStderr(message)}. ` +
550
+ `Fix the ${cli} MCP configuration, then rerun ${localAssimilateCliCommand(apiUrl, cli)}.\n`);
551
+ return { kind: 'stop', code: 1 };
552
+ }
553
+ return continueAssimilation({ resolvedRole, effectiveModel, cli });
554
+ }
555
+ export async function prepareAssimilationSeat(input, deps) {
556
+ const { apiUrl, token, serverTrustIdentity, cubeDetail, resolvedRole, cli, effectiveModel, projectRoot, existing, reattachPriorId, remintInvalidPrior, resumeCredentialRef, resumeDroneId, resumeState, sessionOperation, } = input;
557
+ let sessionExpected;
558
+ if (resumeCredentialRef && resumeState === 'pending') {
559
+ sessionExpected = { kind: 'absent' };
560
+ }
561
+ else if (resumeCredentialRef) {
562
+ sessionExpected = {
563
+ kind: 'exact',
564
+ credentialRef: resumeCredentialRef,
565
+ ...(resumeDroneId ? { droneId: resumeDroneId } : {}),
566
+ };
567
+ }
568
+ else if (remintInvalidPrior && existing?.localSessionCredentialRef) {
569
+ sessionExpected = {
570
+ kind: 'exact',
571
+ credentialRef: existing.localSessionCredentialRef,
572
+ ...(existing.droneId ? { droneId: existing.droneId } : {}),
573
+ };
574
+ }
575
+ else if (reattachPriorId != null && existing?.localSessionCredentialRef && existing.sessionToken) {
576
+ sessionExpected = {
577
+ kind: 'exact',
578
+ credentialRef: existing.localSessionCredentialRef,
579
+ ...(existing.droneId ? { droneId: existing.droneId } : {}),
580
+ sessionDigest: createHash('sha256').update(existing.sessionToken).digest('hex'),
581
+ };
582
+ }
583
+ else {
584
+ sessionExpected = { kind: 'absent' };
585
+ }
586
+ deps.stderr(`Joining cube '${cubeDetail.name}' as ${resolvedRole.name}…\n`);
587
+ let result;
588
+ try {
589
+ result = await deps.assimilate(apiUrl, token, {
590
+ cube_id: cubeDetail.id,
591
+ role_id: resolvedRole.id,
592
+ hostname: deps.getHostname(),
593
+ agent_kind: cli,
594
+ model: effectiveModel,
595
+ working_repo: resolveWorkingRepo(projectRoot),
596
+ ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
597
+ ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
598
+ session_operation: sessionOperation,
599
+ session_expected: sessionExpected,
600
+ revalidate_at_prepare: true,
601
+ }, serverTrustIdentity);
602
+ }
603
+ catch (error) {
604
+ if (error instanceof DroneEvictedError && reattachPriorId != null) {
605
+ deps.stderr(`This worktree's drone on ${apiUrl} was evicted. ` +
606
+ `Remove this worktree, or from a fresh worktree run ${localAssimilateCommand(apiUrl)}.\n`);
607
+ return { kind: 'stop', code: 1 };
608
+ }
609
+ if (error instanceof BorgServerError && reattachPriorId != null) {
610
+ if (error.code === 'SESSION_REVOKED') {
611
+ return { kind: 'stop', code: diagnoseSessionTermination(deps, apiUrl, 'revoked') };
612
+ }
613
+ if (error.code === 'SESSION_REJECTED') {
614
+ return { kind: 'stop', code: diagnoseSessionTermination(deps, apiUrl, 'superseded') };
615
+ }
616
+ }
617
+ return { kind: 'stop', code: reportServerFailure(deps, apiUrl, error) };
618
+ }
619
+ if (result.prepareAborted) {
620
+ deps.stderr(`This worktree's saved connection to ${apiUrl} changed before the attach ` +
621
+ '(a concurrent reset or enroll); no credential was created or sent and nothing was ' +
622
+ `changed. Re-run ${localAssimilateCommand(apiUrl)} to attach against the current state.\n`);
623
+ return { kind: 'stop', code: 1 };
624
+ }
625
+ if (result.local_session === undefined) {
626
+ return {
627
+ kind: 'stop',
628
+ code: reportServerFailure(deps, apiUrl, new Error('Borg server did not return compatible secure session metadata')),
629
+ };
630
+ }
631
+ const assignedRole = cubeDetail.roles.find((role) => role.id === result.role_id) ?? resolvedRole;
632
+ if (result.result === 'reused') {
633
+ deps.stderr(`re-attached as ${result.drone_label} (same session, no new drone minted)\n`);
634
+ }
635
+ else if (assignedRole.id !== resolvedRole.id) {
636
+ deps.stderr(`The requested role "${resolvedRole.name}" was unavailable; ` +
637
+ `attached under the "${assignedRole.name}" role instead.\n`);
638
+ }
639
+ return continueAssimilation({ result, assignedRole, sessionExpected });
640
+ }
641
+ export async function prepareAssimilationWorktree(input, deps) {
642
+ const { flags, repositoryContext, projectRoot, wantSibling, verifiedHead, assignedRole, existing } = input;
643
+ let spawnedWorktreePath = null;
644
+ if (!wantSibling)
645
+ return continueAssimilation({ spawnedWorktreePath });
646
+ const originProbe = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
647
+ let startRef = 'HEAD';
648
+ if (originProbe.status === 0 && originProbe.stdout.trim().length > 0) {
649
+ deps.runSync('git', ['fetch', 'origin'], projectRoot);
650
+ const mainProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/main'], projectRoot);
651
+ if (mainProbe.status === 0) {
652
+ startRef = 'origin/main';
653
+ }
654
+ else if (deps.runSync('git', ['rev-parse', '--verify', 'origin/master'], projectRoot).status === 0) {
655
+ startRef = 'origin/master';
656
+ }
657
+ }
658
+ if (startRef === 'HEAD') {
659
+ deps.stderr(`note: no usable origin; new worktree will start on local HEAD (${verifiedHead.slice(0, 7)})\n`);
660
+ }
661
+ else {
662
+ const remoteHead = deps.runSync('git', ['rev-parse', startRef], projectRoot).stdout.trim();
663
+ if (verifiedHead !== remoteHead) {
664
+ deps.stderr(`note: local HEAD (${verifiedHead.slice(0, 7)}) differs from ${startRef} (${remoteHead.slice(0, 7)}); ` +
665
+ `new worktree will start on ${startRef}\n`);
666
+ }
667
+ }
668
+ const repoBase = basename(dirname(repositoryContext.commonDir));
669
+ const suffix = flags.worktree ?? roleSlug(assignedRole.name);
670
+ if (suffix.length === 0) {
671
+ deps.stderr(`cannot derive a worktree name from role "${assignedRole.name}"; ` +
672
+ 'pass an explicit --worktree <name>\n');
673
+ return { kind: 'stop', code: 1 };
674
+ }
675
+ const homeDir = deps.homedir();
676
+ let registeredWorktrees = listRegisteredWorktrees(deps, projectRoot);
677
+ if (registeredWorktrees === null) {
678
+ deps.stderr('Borg could not enumerate this repository’s existing worktrees, so it did not risk creating a colliding sibling.\n' +
679
+ 'Run `git worktree list` from this repository and resolve the reported Git error, then rerun `borg assimilate`.\n' +
680
+ 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n');
681
+ return { kind: 'stop', code: 1 };
682
+ }
683
+ let candidate = computeWorktreePath(homeDir, repoBase, suffix);
684
+ let worktreeBranch = perWorktreeBranchName(basename(candidate), repoBase);
685
+ let suffixNumber = 2;
686
+ while (deps.pathExists(candidate) ||
687
+ registeredWorktrees.names.has(basename(candidate)) ||
688
+ registeredWorktrees.branches.has(worktreeBranch) ||
689
+ (localBranchExists(deps.runSync, projectRoot, worktreeBranch) &&
690
+ !isMerged(deps.runSync, projectRoot, worktreeBranch, startRef))) {
691
+ candidate = computeWorktreePath(homeDir, repoBase, suffix, suffixNumber);
692
+ worktreeBranch = perWorktreeBranchName(basename(candidate), repoBase);
693
+ suffixNumber++;
694
+ }
695
+ let worktreeResult;
696
+ let residualBranch = null;
697
+ while (true) {
698
+ deps.mkdirp(dirname(candidate));
699
+ const branchExisted = localBranchExists(deps.runSync, projectRoot, worktreeBranch);
700
+ worktreeResult = branchExisted
701
+ ? deps.runSync('git', ['worktree', 'add', candidate, worktreeBranch], projectRoot)
702
+ : deps.runSync('git', ['worktree', 'add', '-b', worktreeBranch, candidate, startRef], projectRoot);
703
+ if (worktreeResult.status === 0)
704
+ break;
705
+ const refreshed = listRegisteredWorktrees(deps, projectRoot);
706
+ const branchAppeared = !branchExisted && localBranchExists(deps.runSync, projectRoot, worktreeBranch);
707
+ const collision = deps.pathExists(candidate) ||
708
+ refreshed?.names.has(basename(candidate)) === true ||
709
+ refreshed?.branches.has(worktreeBranch) === true ||
710
+ (!branchExisted && worktreeAddReportedCollision(worktreeResult.stderr));
711
+ if (!collision || refreshed === null) {
712
+ if (branchAppeared && refreshed?.branches.has(worktreeBranch) !== true)
713
+ residualBranch = worktreeBranch;
714
+ break;
715
+ }
716
+ registeredWorktrees = refreshed;
717
+ do {
718
+ candidate = computeWorktreePath(homeDir, repoBase, suffix, suffixNumber);
719
+ worktreeBranch = perWorktreeBranchName(basename(candidate), repoBase);
720
+ suffixNumber++;
721
+ } while (deps.pathExists(candidate) ||
722
+ registeredWorktrees.names.has(basename(candidate)) ||
723
+ registeredWorktrees.branches.has(worktreeBranch) ||
724
+ localBranchExists(deps.runSync, projectRoot, worktreeBranch));
725
+ }
726
+ if (worktreeResult.status !== 0) {
727
+ deps.stderr(`Borg could not create sibling worktree ${candidate} on branch ${worktreeBranch}. ` +
728
+ `Git reported: ${safeStderr(worktreeResult.stderr)}\n` +
729
+ (residualBranch
730
+ ? `Git left branch ${residualBranch} without a registered worktree; Borg preserved it.\n`
731
+ : '') +
732
+ 'Run `git worktree list` and `git status` to inspect repository state, resolve the reported Git error, then rerun `borg assimilate`.\n' +
733
+ 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n');
734
+ return { kind: 'stop', code: 1 };
735
+ }
736
+ deps.stderr(`spawned sibling worktree at ${candidate} on branch ${worktreeBranch} (${startRef})` +
737
+ (existing !== null
738
+ ? '; the original dir keeps its active drone binding — run `borg reset-local-connection` there if that binding is stale.\n'
739
+ : '.\n'));
740
+ deps.chdir(candidate);
741
+ deps.stderr(renderWorktreeSteeringNote(candidate, worktreeBranch, projectRoot));
742
+ spawnedWorktreePath = deps.cwd();
743
+ return continueAssimilation({ spawnedWorktreePath });
744
+ }
745
+ export async function resolveAssimilationAuthority(input, deps) {
746
+ const { args, mode, repositoryContext } = input;
317
747
  const hostlessEnrollment = args.flags.enroll === true &&
318
748
  args.flags.server === undefined && deps.defaultAuthority === undefined;
319
749
  const artifactOnlyEnrollment = hostlessEnrollment && deps.isTTY();
@@ -324,13 +754,8 @@ export async function runAssimilate(args, deps, options = {}) {
324
754
  if (hostlessEnrollment && !deps.isTTY()) {
325
755
  deps.stderr('Local enrollment requires an interactive operator terminal. ' +
326
756
  `Re-run ${localAssimilateCommand(undefined, true, mode)} from the operator’s terminal.\n`);
327
- return 1;
757
+ return { kind: 'stop', code: 1 };
328
758
  }
329
- // An explicit --host plus a new invitation must be rejected on the pure
330
- // input path. Decode and compare the operator-presented artifact before any
331
- // pending-enrollment lookup: the lookup enumerates the credential backend,
332
- // so it must not precede this contradiction check. A matching artifact may
333
- // then take the published pending-resume path until client#267 lands.
334
759
  if (args.flags.enroll && args.flags.server !== undefined && deps.isTTY()) {
335
760
  let preResumeOrigin;
336
761
  try {
@@ -338,12 +763,12 @@ export async function runAssimilate(args, deps, options = {}) {
338
763
  }
339
764
  catch (error) {
340
765
  deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
341
- return 1;
766
+ return { kind: 'stop', code: 1 };
342
767
  }
343
768
  prefetchedInvitation = await deps.promptSecret('Enrollment invitation (single-use; hidden input):');
344
769
  if (!prefetchedInvitation) {
345
770
  deps.stderr('No enrollment invitation was entered. Ask the server operator for one, then retry.\n');
346
- return 1;
771
+ return { kind: 'stop', code: 1 };
347
772
  }
348
773
  try {
349
774
  prefetchedArtifact = decodeAndVerifyInvitationArtifact(prefetchedInvitation);
@@ -353,12 +778,10 @@ export async function runAssimilate(args, deps, options = {}) {
353
778
  }
354
779
  catch (error) {
355
780
  deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
356
- prefetchedInvitation = undefined;
357
- prefetchedArtifact = undefined;
358
- return 1;
781
+ return { kind: 'stop', code: 1 };
359
782
  }
360
783
  let pendingForHost = false;
361
- if (deps.peekPendingServerEnrollment !== undefined) {
784
+ if (deps.peekPendingServerEnrollment) {
362
785
  let pending = null;
363
786
  try {
364
787
  pending = await deps.peekPendingServerEnrollment();
@@ -376,7 +799,7 @@ export async function runAssimilate(args, deps, options = {}) {
376
799
  }
377
800
  catch (error) {
378
801
  deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
379
- return 1;
802
+ return { kind: 'stop', code: 1 };
380
803
  }
381
804
  }
382
805
  }
@@ -394,29 +817,24 @@ export async function runAssimilate(args, deps, options = {}) {
394
817
  }
395
818
  }
396
819
  }
397
- if (artifactOnlyEnrollment ||
398
- preResumeAttempted && preResumedEnrollment === null) {
820
+ if (artifactOnlyEnrollment || preResumeAttempted && preResumedEnrollment === null) {
399
821
  if (artifactOnlyEnrollment && deps.resumePendingServerEnrollment) {
400
822
  preResumedEnrollment = await deps.resumePendingServerEnrollment(() => {
401
823
  deps.stderr('Resuming the pending enrollment; no new invitation is required.\n');
402
824
  });
403
825
  }
404
- if (artifactOnlyEnrollment && preResumedEnrollment) {
405
- // The exact pending tuple was already redeemed or is being resumed.
406
- }
407
- else {
826
+ if (!(artifactOnlyEnrollment && preResumedEnrollment)) {
408
827
  prefetchedInvitation = await deps.promptSecret('Enrollment invitation (single-use; hidden input):');
409
828
  if (!prefetchedInvitation) {
410
829
  deps.stderr('No enrollment invitation was entered. Ask the server operator for one, then retry.\n');
411
- return 1;
830
+ return { kind: 'stop', code: 1 };
412
831
  }
413
832
  try {
414
833
  prefetchedArtifact = decodeAndVerifyInvitationArtifact(prefetchedInvitation);
415
834
  }
416
835
  catch (error) {
417
836
  deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
418
- prefetchedInvitation = undefined;
419
- return 1;
837
+ return { kind: 'stop', code: 1 };
420
838
  }
421
839
  }
422
840
  }
@@ -426,10 +844,7 @@ export async function runAssimilate(args, deps, options = {}) {
426
844
  : 'borg assimilate --host <host>';
427
845
  const serverInstall = await deps.ensureLocalServerInstalled(connectCommand);
428
846
  if (serverInstall !== 'present') {
429
- // A newly installed server still needs its explicit setup/start journey.
430
- // Decline, non-interactive, and failure paths have already printed exact
431
- // recovery commands. None may continue into private-state mutation.
432
- return serverInstall === 'installed' ? 0 : 1;
847
+ return { kind: 'stop', code: serverInstall === 'installed' ? 0 : 1 };
433
848
  }
434
849
  }
435
850
  try {
@@ -437,11 +852,8 @@ export async function runAssimilate(args, deps, options = {}) {
437
852
  }
438
853
  catch {
439
854
  deps.stderr(`${PRIVATE_STATE_UNAVAILABLE_COPY}\n`);
440
- return 1;
855
+ return { kind: 'stop', code: 1 };
441
856
  }
442
- // Read local seat state before authority discovery, which may probe the local
443
- // server. A retired replacement collision must not send either saved bearer or
444
- // perform any other network request.
445
857
  let existing = null;
446
858
  let hasPersistedIdentity = false;
447
859
  let localSeatReadError;
@@ -451,17 +863,16 @@ export async function runAssimilate(args, deps, options = {}) {
451
863
  }
452
864
  catch (error) {
453
865
  if (error instanceof LegacySessionCredentialCollisionError) {
454
- return reportServerFailure(deps, error.origin, error, false, mode);
866
+ return { kind: 'stop', code: reportServerFailure(deps, error.origin, error, false, mode) };
455
867
  }
456
868
  localSeatReadError = error;
457
869
  }
458
- // ----- Step 1: Select and authenticate the local server -----
459
870
  const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode);
460
871
  if (!selectedAuthority)
461
- return 1;
872
+ return { kind: 'stop', code: 1 };
462
873
  let authority = selectedAuthority;
463
874
  if (localSeatReadError !== undefined) {
464
- return reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode);
875
+ return { kind: 'stop', code: reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode) };
465
876
  }
466
877
  const projectRoot = repositoryContext.root;
467
878
  const wantSibling = args.flags.worktree !== undefined ||
@@ -470,98 +881,108 @@ export async function runAssimilate(args, deps, options = {}) {
470
881
  if (mode !== 'cube-init' && args.flags.here && existing === null && !hasPersistedIdentity) {
471
882
  deps.stderr('`borg assimilate --here` resumes this worktree\'s saved drone, but no saved drone was found.\n' +
472
883
  'Run `borg assimilate` to create a new drone in a managed worktree.\n');
473
- return 1;
884
+ return { kind: 'stop', code: 1 };
474
885
  }
475
886
  if (mode !== 'cube-init' && wantSibling) {
476
887
  const headProbe = deps.runSync('git', ['rev-parse', '--verify', 'HEAD'], projectRoot);
477
888
  if (headProbe.status !== 0) {
478
889
  deps.stderr('sibling worktree spawn requires HEAD pointing at a commit.\n' +
479
890
  'Create an initial commit (for example: `git commit --allow-empty -m "Initial commit"`), then rerun `borg assimilate`.\n');
480
- return 1;
891
+ return { kind: 'stop', code: 1 };
481
892
  }
482
893
  verifiedHead = headProbe.stdout.trim();
483
894
  }
484
895
  let auth;
485
- {
486
- try {
487
- let serverAuth;
488
- if (args.flags.enroll) {
489
- if (!deps.isTTY()) {
490
- deps.stderr('Local enrollment requires an interactive operator terminal. ' +
491
- `Re-run ${localAssimilateCommand(authority.apiUrl, true, mode)} from the operator’s terminal.\n`);
492
- return 1;
493
- }
494
- let resumed = preResumedEnrollment;
495
- if (!resumed && prefetchedArtifact === undefined && !preResumeAttempted && !artifactOnlyEnrollment) {
496
- resumed = await deps.resumeServerEnrollment(authority.apiUrl, () => {
497
- deps.stderr(`Resuming the pending enrollment for \`${authority.apiUrl}\`; ` +
498
- 'do not enter another invitation unless the server certificate was reissued; ' +
499
- 'if it was, request a current invitation and rerun this command.\n');
500
- });
501
- }
502
- if (resumed) {
503
- if ('apiUrl' in resumed && typeof resumed.apiUrl === 'string') {
504
- authority = { kind: 'server', apiUrl: resumed.apiUrl };
505
- }
506
- serverAuth = resumed;
896
+ try {
897
+ let serverAuth;
898
+ if (args.flags.enroll) {
899
+ if (!deps.isTTY()) {
900
+ deps.stderr('Local enrollment requires an interactive operator terminal. ' +
901
+ `Re-run ${localAssimilateCommand(authority.apiUrl, true, mode)} from the operator’s terminal.\n`);
902
+ return { kind: 'stop', code: 1 };
903
+ }
904
+ let resumed = preResumedEnrollment;
905
+ if (!resumed && prefetchedArtifact === undefined && !preResumeAttempted && !artifactOnlyEnrollment) {
906
+ resumed = await deps.resumeServerEnrollment(authority.apiUrl, () => {
907
+ deps.stderr(`Resuming the pending enrollment for \`${authority.apiUrl}\`; ` +
908
+ 'do not enter another invitation unless the server certificate was reissued; ' +
909
+ 'if it was, request a current invitation and rerun this command.\n');
910
+ });
911
+ }
912
+ if (resumed) {
913
+ if (resumed.apiUrl)
914
+ authority = { kind: 'server', apiUrl: resumed.apiUrl };
915
+ serverAuth = resumed;
916
+ }
917
+ else {
918
+ let invitation = prefetchedInvitation ?? await deps.promptSecret(artifactOnlyEnrollment
919
+ ? 'Enrollment invitation (single-use; hidden input):'
920
+ : `Enrollment invitation for \`${authority.apiUrl}\` (single-use; hidden input):`);
921
+ if (!invitation) {
922
+ deps.stderr(artifactOnlyEnrollment
923
+ ? 'No enrollment invitation was entered. Ask the server operator for one, then rerun `borg assimilate --enroll`.\n'
924
+ : `No enrollment invitation was entered for ${authority.apiUrl}. Ask the server operator for one, then rerun ${localAssimilateCommand(authority.apiUrl, true, mode)}.\n`);
925
+ return { kind: 'stop', code: 1 };
507
926
  }
508
- else {
509
- let invitation = prefetchedInvitation ?? await deps.promptSecret(artifactOnlyEnrollment
510
- ? 'Enrollment invitation (single-use; hidden input):'
511
- : `Enrollment invitation for \`${authority.apiUrl}\` (single-use; hidden input):`);
512
- if (!invitation) {
513
- deps.stderr(artifactOnlyEnrollment
514
- ? 'No enrollment invitation was entered. Ask the server operator for one, then rerun `borg assimilate --enroll`.\n'
515
- : `No enrollment invitation was entered for ${authority.apiUrl}. ` +
516
- `Ask the server operator for one, then rerun ${localAssimilateCommand(authority.apiUrl, true, mode)}.\n`);
517
- return 1;
518
- }
519
- try {
520
- const artifact = prefetchedArtifact ?? decodeAndVerifyInvitationArtifact(invitation);
521
- if (args.flags.server !== undefined && authority.apiUrl !== artifact.endpoint) {
522
- throw new InvitationArtifactEndpointMismatchError(authority.apiUrl, artifact.endpoint);
523
- }
524
- authority = { kind: 'server', apiUrl: artifact.endpoint };
525
- serverAuth = await deps.connectServer(authority.apiUrl, {
526
- invitation,
527
- artifact,
528
- confirmReplacement: async () => strictAffirmative(await deps.prompt(`A local enrollment for ${authority.apiUrl} already exists. Replacing it will orphan ` +
529
- 'the first enrolled client. Replace it? [y/N]: ')),
530
- });
531
- }
532
- finally {
533
- // Strings cannot be zeroized in JavaScript, but drop this command's
534
- // reference immediately after the exchange instead of retaining the
535
- // invitation through the rest of assimilation/agent launch.
536
- invitation = '';
927
+ try {
928
+ const artifact = prefetchedArtifact ?? decodeAndVerifyInvitationArtifact(invitation);
929
+ if (args.flags.server !== undefined && authority.apiUrl !== artifact.endpoint) {
930
+ throw new InvitationArtifactEndpointMismatchError(authority.apiUrl, artifact.endpoint);
537
931
  }
932
+ authority = { kind: 'server', apiUrl: artifact.endpoint };
933
+ serverAuth = await deps.connectServer(authority.apiUrl, {
934
+ invitation,
935
+ artifact,
936
+ confirmReplacement: async () => strictAffirmative(await deps.prompt(`A local enrollment for ${authority.apiUrl} already exists. Replacing it will orphan ` +
937
+ 'the first enrolled client. Replace it? [y/N]: ')),
938
+ });
538
939
  }
539
- if (serverAuth.serverCapabilities?.includes('create_cube')) {
540
- deps.stderr(`Owner client enrolled with \`${authority.apiUrl}\`. ` +
541
- 'Creating or joining this repository’s cube next.\n');
542
- }
543
- else {
544
- deps.stderr(`Ordinary client enrolled with \`${authority.apiUrl}\`. ` +
545
- 'Checking for an accessible repository cube next.\n');
940
+ finally {
941
+ invitation = '';
546
942
  }
547
943
  }
548
- else {
549
- serverAuth = await deps.connectServer(authority.apiUrl);
550
- }
551
- auth = {
552
- token: serverAuth.token,
553
- apiUrl: authority.apiUrl,
554
- serverTrustIdentity: serverAuth.trustIdentity,
555
- serverCapabilities: serverAuth.serverCapabilities ?? [],
556
- };
557
- if (args.flags.enroll) {
558
- deps.stderr(`This machine (${deps.getHostname()}) is enrolled with Borg server \`${authority.apiUrl}\`.\n`);
559
- }
944
+ deps.stderr(serverAuth.serverCapabilities?.includes('create_cube')
945
+ ? `Owner client enrolled with \`${authority.apiUrl}\`. Creating or joining this repository’s cube next.\n`
946
+ : `Ordinary client enrolled with \`${authority.apiUrl}\`. Checking for an accessible repository cube next.\n`);
560
947
  }
561
- catch (error) {
562
- return reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode);
948
+ else {
949
+ serverAuth = await deps.connectServer(authority.apiUrl);
950
+ }
951
+ auth = {
952
+ token: serverAuth.token,
953
+ apiUrl: authority.apiUrl,
954
+ serverTrustIdentity: serverAuth.trustIdentity,
955
+ serverCapabilities: serverAuth.serverCapabilities ?? [],
956
+ };
957
+ if (args.flags.enroll) {
958
+ deps.stderr(`This machine (${deps.getHostname()}) is enrolled with Borg server \`${authority.apiUrl}\`.\n`);
563
959
  }
564
960
  }
961
+ catch (error) {
962
+ return {
963
+ kind: 'stop',
964
+ code: reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode),
965
+ };
966
+ }
967
+ return continueAssimilation({
968
+ authority,
969
+ auth,
970
+ existing,
971
+ hasPersistedIdentity,
972
+ projectRoot,
973
+ wantSibling,
974
+ verifiedHead,
975
+ });
976
+ }
977
+ export async function runAssimilate(args, deps, options = {}) {
978
+ const repository = await resolveAssimilationRepository(args, deps);
979
+ if (repository.kind === 'stop')
980
+ return repository.code;
981
+ const { mode, repositoryContext } = repository.value;
982
+ const authorityResolution = await resolveAssimilationAuthority({ args, mode, repositoryContext }, deps);
983
+ if (authorityResolution.kind === 'stop')
984
+ return authorityResolution.code;
985
+ const { authority, auth, existing, hasPersistedIdentity, projectRoot, wantSibling, verifiedHead, } = authorityResolution.value;
565
986
  // ----- Sprint 19 (gh#184): Reorder for strict-rollback semantics. -----
566
987
  // The previous flow created a sibling worktree (FS state) BEFORE
567
988
  // role resolution + API assimilate. Any early-return between
@@ -910,352 +1331,49 @@ export async function runAssimilate(args, deps, options = {}) {
910
1331
  return 1;
911
1332
  }
912
1333
  }
913
- // ----- Step 5: Role resolution -----
914
- let resolvedRole;
915
- if (savedLocalRole) {
916
- resolvedRole = savedLocalRole;
917
- }
918
- else if (args.role !== undefined) {
919
- resolvedRole = matchRoleByName(cubeDetail.roles, args.role);
920
- if (!resolvedRole) {
921
- // Sprint 19 (gh#184) + drone-7 metaphor argument: include a
922
- // fuzzy-match "did you mean ...?" suggestion to serve Queen's
923
- // "more user-friendly" intent without violating the
924
- // Borg-collective metaphor (collective defines roles; drones
925
- // slot in). Levenshtein distance ≤2 on the cube's role names.
926
- const available = cubeDetail.roles.map((r) => r.name).join(', ');
927
- const suggestion = suggestRoleName(args.role, cubeDetail.roles.map((r) => r.name));
928
- const suggestionLine = suggestion ? ` Did you mean "${suggestion}"?` : '';
929
- if (authority.kind === 'server') {
930
- deps.stderr(`No role matching "${args.role}" in cube "${cubeDetail.name}" on ${authority.apiUrl}. ` +
931
- `Available: ${available}.${suggestionLine}\n` +
932
- `Rerun ${localAssimilateRoleCommand(authority.apiUrl)} with one of the available roles.\n`);
933
- }
934
- else {
935
- deps.stderr(`no role matching "${args.role}" in cube "${cubeDetail.name}". Available: ${available}.${suggestionLine}\n` +
936
- `(Use --template <name> on first-drone setup or run \`borg_create-role\` from inside Claude.)\n`);
937
- }
938
- return 1;
939
- }
940
- }
941
- else {
942
- const occupiedRoleIds = occupiedRoleIdsForAutoRole(cubeDetail.drones ?? []);
943
- resolvedRole = pickDefaultRole(cubeDetail.roles, { isFirstDrone, occupiedRoleIds });
944
- if (!resolvedRole) {
945
- if (authority.kind === 'server') {
946
- deps.stderr(`Cube "${cubeDetail.name}" on ${authority.apiUrl} has no default or human-seat role. ` +
947
- `Ask the server operator to configure a role, then rerun ` +
948
- `${localAssimilateRoleCommand(authority.apiUrl)}.\n`);
949
- }
950
- else {
951
- deps.stderr(`cube "${cubeDetail.name}" has no default or human-seat role; cannot infer a role. ` +
952
- `Either pass a role argument explicitly (e.g. \`borg assimilate builder\`) or ` +
953
- `run \`borg_create-role\` from inside Claude to set up roles.\n`);
954
- }
955
- return 1;
956
- }
957
- }
958
- // ----- Step 5b: --here collision check BEFORE the API mint (gh#780) -----
959
- // Pre-gh#780 this check lived in Step 7 — AFTER the API assimilate — so a
960
- // `--here` run in a directory that already hosts a drone minted a fresh
961
- // drones row server-side, then aborted before Step 8 ever persisted the
962
- // mapping: an orphan seat with no local identity. The check must precede
963
- // the mint. (The full worktree DECISION stays in Step 7 by design — FS
964
- // state only after API success; this hoists only the abort case.)
965
- //
966
- // PR-D refinement: --here + existing + SAME authority/cube is the
967
- // saved-seat recovery flow. The local
968
- // seats first prove liveness with their keychained session, then reuse the
969
- // saved role/retry binding; only authoritative eviction rotates that retry.
970
- // Role defaults and local launch state do not select the model. The explicit
971
- // Claude-only flag remains temporarily for compatibility with existing
972
- // invocations.
973
- const effectiveModel = args.flags.model ?? null;
974
- // Resolve the agent CLI now so the worker learns agent_kind AT assimilate
975
- // time.
976
- const cli = await deps.resolveCli(args.flags.cli);
977
- try {
978
- ensureCliMcpConfigured(cli);
979
- }
980
- catch (err) {
981
- const message = err instanceof Error ? err.message : String(err);
982
- if (authority.kind === 'server') {
983
- deps.stderr(`${cli} MCP configuration failed for ${authority.apiUrl}: ${safeStderr(message)}. ` +
984
- `Fix the ${cli} MCP configuration, then rerun ` +
985
- `${localAssimilateCliCommand(authority.apiUrl, cli)}.\n`);
986
- }
987
- else {
988
- deps.stderr(`${cli} MCP configuration failed: ${message}\n`);
989
- }
990
- return 1;
991
- }
992
- // The TYPED prepare-time expectation (ratified clause 3 / CR #1). Declared HERE,
993
- // BEFORE the mint+send, and revalidated at BOTH the cube-lock-held PREPARE (so a
994
- // reset that wins before PREPARE aborts before any credential is created/sent)
995
- // and FINALIZE. resume/reattach/remint pin the FULL prior binding (ref + drone
996
- // id [+ live digest]); fresh/sibling declare ABSENT.
997
- let sessionExpected;
998
- if (resumeCredentialRef && resumeState === 'pending') {
999
- // CR#2: a bound-PENDING resume (a sibling whose activation failed) re-sends the
1000
- // identical pending bearer the server already digest-bound. A PENDING record is
1001
- // NOT a live binding, so it declares ABSENT (pending-reuse): prepareSeat REUSES
1002
- // the existing pending record (identical bearer). An EXACT expectation would be
1003
- // rejected by prepareSeat's `prior.state==='active'` guard and abort the only
1004
- // ghost-free recovery.
1005
- sessionExpected = { kind: 'absent' };
1006
- }
1007
- else if (resumeCredentialRef) {
1008
- sessionExpected = {
1009
- kind: 'exact',
1010
- credentialRef: resumeCredentialRef,
1011
- ...(resumeDroneId ? { droneId: resumeDroneId } : {}),
1012
- };
1013
- }
1014
- else if (remintInvalidPrior && existing?.localSessionCredentialRef) {
1015
- sessionExpected = {
1016
- kind: 'exact',
1017
- credentialRef: existing.localSessionCredentialRef,
1018
- ...(existing.droneId ? { droneId: existing.droneId } : {}),
1019
- };
1020
- }
1021
- else if (reattachPriorId != null && existing?.localSessionCredentialRef && existing.sessionToken) {
1022
- sessionExpected = {
1023
- kind: 'exact',
1024
- credentialRef: existing.localSessionCredentialRef,
1025
- ...(existing.droneId ? { droneId: existing.droneId } : {}),
1026
- sessionDigest: createHash('sha256').update(existing.sessionToken).digest('hex'),
1027
- };
1028
- }
1029
- else {
1030
- sessionExpected = { kind: 'absent' };
1031
- }
1032
- // CR1(b): PREPARE-time revalidation is preserved for siblings too. A sibling
1033
- // declares an ABSENT expectation: a PENDING record at the ref (a lost-response
1034
- // retry / crash-in-gap) stays reusable so the identical bearer is re-sent, but an
1035
- // ACTIVE record holding the ref is a mismatch → abort (never silently reuse/move
1036
- // a live binding). With the collision-safe sibling key above the fresh ref is
1037
- // normally empty, so ABSENT passes and the mint proceeds; the check is the
1038
- // defense that stops an active seat from being unseated.
1039
- const revalidateAtPrepare = true;
1040
- // ----- Step 6: API assimilate (no FS state yet — clean exit on failure) -----
1041
- // gh#653 B4: progress for the seat-mint round-trip (silent-window stall).
1042
- deps.stderr(`Joining cube '${cubeDetail.name}' as ${resolvedRole.name}…\n`);
1043
- let result;
1044
- try {
1045
- const assimilateParams = {
1046
- cube_id: cubeDetail.id,
1047
- role_id: resolvedRole.id,
1048
- hostname: deps.getHostname(),
1049
- agent_kind: cli,
1050
- model: effectiveModel,
1051
- working_repo: resolveWorkingRepo(projectRoot),
1052
- ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
1053
- ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
1054
- session_operation: sessionOperation,
1055
- session_expected: sessionExpected,
1056
- revalidate_at_prepare: revalidateAtPrepare,
1057
- };
1058
- result = await deps.assimilate(auth.apiUrl, auth.token, assimilateParams, auth.serverTrustIdentity);
1059
- }
1060
- catch (err) {
1061
- // gh#877 follow-up: a re-attach (`--here`) whose saved seat was evicted is
1062
- // REFUSED server-side (410 DRONE_EVICTED) rather than silently re-minting a
1063
- // fresh drone. Surface the terminal recovery path instead of the generic
1064
- // "assimilate failed". Only on a reattach attempt (reattachPriorId set);
1065
- // a non-reattach DroneEvictedError falls through to the generic message.
1066
- if (err instanceof DroneEvictedError && reattachPriorId != null) {
1067
- deps.stderr(`This worktree's drone on ${authority.apiUrl} was evicted. ` +
1068
- `Remove this worktree, or from a fresh worktree run ` +
1069
- `${localAssimilateCommand(authority.apiUrl)}.\n`);
1070
- return 1;
1071
- }
1072
- // Pin-matched terminal session outcomes are pure diagnosis.
1073
- // Reached only after a successful pinned-TLS attach, so it is pin-matched by
1074
- // construction — a pin mismatch throws a distinct trust error and never
1075
- // enters this branch. Attach mutates NOTHING; it recommends the offline
1076
- // `borg reset-local-connection` command.
1077
- if (err instanceof BorgServerError && reattachPriorId != null) {
1078
- if (err.code === 'SESSION_REVOKED') {
1079
- return diagnoseSessionTermination(deps, authority.apiUrl, 'revoked');
1080
- }
1081
- if (err.code === 'SESSION_REJECTED') {
1082
- return diagnoseSessionTermination(deps, authority.apiUrl, 'superseded');
1083
- }
1084
- }
1085
- if (authority.kind === 'server') {
1086
- return reportServerFailure(deps, authority.apiUrl, err);
1087
- }
1088
- const message = err instanceof Error ? err.message : String(err);
1089
- deps.stderr(`assimilate failed: ${message}\n`);
1090
- return 1;
1091
- }
1092
- if (authority.kind === 'server' && result.prepareAborted) {
1093
- // CR #1: the cube-lock-held PREPARE revalidation aborted BEFORE any credential
1094
- // was minted or sent — this worktree's saved seat changed under us (a
1095
- // concurrent offline reset, or a competing enroll). No FS/network mutation
1096
- // happened; never silently recreate.
1097
- deps.stderr(`This worktree's saved connection to ${authority.apiUrl} changed before the attach ` +
1098
- '(a concurrent reset or enroll); no credential was created or sent and nothing was ' +
1099
- `changed. Re-run ${localAssimilateCommand(authority.apiUrl)} to attach against the ` +
1100
- 'current state.\n');
1101
- return 1;
1102
- }
1103
- if (authority.kind === 'server' && result.local_session === undefined) {
1104
- return reportServerFailure(deps, authority.apiUrl, new Error('Borg server did not return compatible secure session metadata'));
1105
- }
1106
- // The server may assimilate a member into a DIFFERENT role than the client's
1107
- // auto-picked default (gh#700 fallback: when the member's invite doesn't
1108
- // grant the default role, the server picks one of their GRANTED roles).
1109
- // Resolve the role the SERVER ACTUALLY assigned (result.role_id) and use it
1110
- // for all human-facing display + naming below — not the client's pre-pick.
1111
- // The drone label / session token are already server-truth; this aligns the
1112
- // displayed role name + worktree slug with what was actually assigned.
1113
- const assignedRole = cubeDetail.roles.find((r) => r.id === result.role_id) ?? resolvedRole;
1114
- if (result.result === 'reused') {
1115
- // The drone's existing role is authoritative on an idempotent reattach —
1116
- // a role difference is expected, not a grant fallback. The bearer is
1117
- // reused, not rotated: no new drone minted.
1118
- deps.stderr(`re-attached as ${result.drone_label} (same session, no new drone minted)\n`);
1119
- }
1120
- else if (assignedRole.id !== resolvedRole.id) {
1121
- deps.stderr(`The requested role "${resolvedRole.name}" was unavailable; ` +
1122
- `attached under the "${assignedRole.name}" role instead.\n`);
1123
- }
1124
- // ----- Step 7: Worktree decision (FS state ONLY after API success) -----
1125
- // (`existing` was read at Step 5b; a different-cube --here collision
1126
- // already aborted there, pre-mint. The surviving --here + existing case
1127
- // is the SAME-cube reattach — an in-place recovery, never a sibling
1128
- // spawn.)
1129
- let spawnedWorktreePath = null;
1130
- if (wantSibling) {
1131
- const localHead = verifiedHead;
1132
- const originProbe = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
1133
- let startRef = 'HEAD';
1134
- if (originProbe.status === 0 && originProbe.stdout.trim().length > 0) {
1135
- // gh#238: when origin exists, fetch it so the new worktree starts on the
1136
- // latest remote default branch rather than a possibly stale local HEAD.
1137
- deps.runSync('git', ['fetch', 'origin'], projectRoot);
1138
- const mainProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/main'], projectRoot);
1139
- if (mainProbe.status === 0) {
1140
- startRef = 'origin/main';
1141
- }
1142
- else {
1143
- const masterProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/master'], projectRoot);
1144
- if (masterProbe.status === 0) {
1145
- startRef = 'origin/master';
1146
- }
1147
- }
1148
- }
1149
- if (startRef === 'HEAD') {
1150
- deps.stderr(`note: no usable origin; new worktree will start on local HEAD (${localHead.slice(0, 7)})\n`);
1151
- }
1152
- else {
1153
- // Warn if local HEAD diverges from the remote default branch.
1154
- const remoteHead = deps.runSync('git', ['rev-parse', startRef], projectRoot).stdout.trim();
1155
- if (localHead !== remoteHead) {
1156
- deps.stderr(`note: local HEAD (${localHead.slice(0, 7)}) differs from ${startRef} (${remoteHead.slice(0, 7)}); ` +
1157
- `new worktree will start on ${startRef}\n`);
1158
- }
1159
- }
1160
- // The common Git directory identifies the repository across every linked
1161
- // worktree. Using projectRoot here fragments one repository's siblings when
1162
- // assimilation starts inside an existing sibling worktree.
1163
- const repoBase = basename(dirname(repositoryContext.commonDir));
1164
- const suffix = args.flags.worktree ?? roleSlug(assignedRole.name);
1165
- // gh#556 Part 1: empty-suffix guard (CR-binding). roleSlug can yield '' for a
1166
- // pathological all-special-char role name; an empty leaf would let join() collapse
1167
- // the worktree path up to the repo-level dir (~/.borg/worktrees/<repo>) and spawn a
1168
- // worktree at the parent-of-all-this-repo's-worktrees. Fail loud BEFORE the path calc.
1169
- if (suffix.length === 0) {
1170
- deps.stderr(`cannot derive a worktree name from role "${assignedRole.name}"; ` +
1171
- `pass an explicit --worktree <name>\n`);
1172
- return 1;
1173
- }
1174
- // gh#556 Part 1: NEW worktrees live under ~/.borg/worktrees/<repo>/<name>
1175
- // (was a sibling <parent>/<repo>-<name>). Existing siblings are untouched
1176
- // (absolute git-registered paths). Collision dedup KEPT (<name>-<n>).
1177
- const homeDir = deps.homedir();
1178
- let registeredWorktrees = listRegisteredWorktrees(deps, projectRoot);
1179
- if (registeredWorktrees === null) {
1180
- deps.stderr('Borg could not enumerate this repository’s existing worktrees, so it did not risk creating a colliding sibling.\n' +
1181
- 'Run `git worktree list` from this repository and resolve the reported Git error, then rerun `borg assimilate`.\n' +
1182
- 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n');
1183
- return 1;
1184
- }
1185
- let candidate = computeWorktreePath(homeDir, repoBase, suffix);
1186
- let wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1187
- let n = 2;
1188
- // gh#864: dedup against an existing worktree PATH/registration AND against a
1189
- // lingering UNMERGED per-worktree branch. `git worktree add -b <wtBranch>`
1190
- // (below) hard-fails when <wtBranch> already exists even if its old worktree
1191
- // was pruned — so a stale ref would block the spawn. A MERGED lingering
1192
- // branch is safely adoptable (handled at the add), so it does NOT force a
1193
- // suffix bump; only an UNMERGED ref (carrying un-merged commits) bumps to a
1194
- // fresh suffix so we never reuse/clobber its work.
1195
- while (deps.pathExists(candidate) ||
1196
- registeredWorktrees.names.has(basename(candidate)) ||
1197
- registeredWorktrees.branches.has(wtBranch) ||
1198
- (localBranchExists(deps.runSync, projectRoot, wtBranch) &&
1199
- !isMerged(deps.runSync, projectRoot, wtBranch, startRef))) {
1200
- candidate = computeWorktreePath(homeDir, repoBase, suffix, n);
1201
- wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1202
- n++;
1203
- }
1204
- let wt;
1205
- let residualBranch = null;
1206
- while (true) {
1207
- // gh#556 Part 1: create the intermediate ~/.borg/worktrees/<repo>/ before
1208
- // `git worktree add` (git creates the leaf, not the parent chain). Plain
1209
- // recursive mkdir — NO chmod of the existing ~/.borg (credentials file).
1210
- deps.mkdirp(dirname(candidate));
1211
- const branchExisted = localBranchExists(deps.runSync, projectRoot, wtBranch);
1212
- wt = branchExisted
1213
- ? deps.runSync('git', ['worktree', 'add', candidate, wtBranch], projectRoot)
1214
- : deps.runSync('git', ['worktree', 'add', '-b', wtBranch, candidate, startRef], projectRoot);
1215
- if (wt.status === 0)
1216
- break;
1217
- // Another assimilate may claim the name or branch after our first list.
1218
- // Refresh and suffix-bump instead of surfacing a collision to the operator.
1219
- const refreshed = listRegisteredWorktrees(deps, projectRoot);
1220
- const branchAppeared = !branchExisted && localBranchExists(deps.runSync, projectRoot, wtBranch);
1221
- const collision = deps.pathExists(candidate) ||
1222
- refreshed?.names.has(basename(candidate)) === true ||
1223
- refreshed?.branches.has(wtBranch) === true ||
1224
- (!branchExisted && worktreeAddReportedCollision(wt.stderr));
1225
- if (!collision || refreshed === null) {
1226
- if (branchAppeared && refreshed?.branches.has(wtBranch) !== true) {
1227
- residualBranch = wtBranch;
1228
- }
1229
- break;
1230
- }
1231
- registeredWorktrees = refreshed;
1232
- do {
1233
- candidate = computeWorktreePath(homeDir, repoBase, suffix, n);
1234
- wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1235
- n++;
1236
- } while (deps.pathExists(candidate) ||
1237
- registeredWorktrees.names.has(basename(candidate)) ||
1238
- registeredWorktrees.branches.has(wtBranch) ||
1239
- localBranchExists(deps.runSync, projectRoot, wtBranch));
1240
- }
1241
- if (wt.status !== 0) {
1242
- deps.stderr(`Borg could not create sibling worktree ${candidate} on branch ${wtBranch}. ` +
1243
- `Git reported: ${safeStderr(wt.stderr)}\n` +
1244
- (residualBranch
1245
- ? `Git left branch ${residualBranch} without a registered worktree; Borg preserved it.\n`
1246
- : '') +
1247
- 'Run `git worktree list` and `git status` to inspect repository state, resolve the reported Git error, then rerun `borg assimilate`.\n' +
1248
- 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n');
1249
- return 1;
1250
- }
1251
- deps.stderr(`spawned sibling worktree at ${candidate} on branch ${wtBranch} (${startRef})` +
1252
- (existing !== null
1253
- ? `; the original dir keeps its active drone binding — run \`borg reset-local-connection\` there if that binding is stale.\n`
1254
- : '.\n'));
1255
- deps.chdir(candidate);
1256
- deps.stderr(renderWorktreeSteeringNote(candidate, wtBranch, projectRoot));
1257
- spawnedWorktreePath = deps.cwd();
1258
- }
1334
+ const cubeRole = await resolveAssimilationCubeRole({
1335
+ requestedRole: args.role,
1336
+ flags: args.flags,
1337
+ cubeDetail,
1338
+ isFirstDrone,
1339
+ savedLocalRole,
1340
+ apiUrl: authority.apiUrl,
1341
+ }, deps);
1342
+ if (cubeRole.kind === 'stop')
1343
+ return cubeRole.code;
1344
+ const { resolvedRole, effectiveModel, cli } = cubeRole.value;
1345
+ const seat = await prepareAssimilationSeat({
1346
+ apiUrl: auth.apiUrl,
1347
+ token: auth.token,
1348
+ serverTrustIdentity: auth.serverTrustIdentity,
1349
+ cubeDetail,
1350
+ resolvedRole,
1351
+ cli,
1352
+ effectiveModel,
1353
+ projectRoot,
1354
+ existing,
1355
+ reattachPriorId,
1356
+ remintInvalidPrior,
1357
+ resumeCredentialRef,
1358
+ resumeDroneId,
1359
+ resumeState,
1360
+ sessionOperation,
1361
+ }, deps);
1362
+ if (seat.kind === 'stop')
1363
+ return seat.code;
1364
+ const { result, assignedRole, sessionExpected } = seat.value;
1365
+ const worktree = await prepareAssimilationWorktree({
1366
+ flags: args.flags,
1367
+ repositoryContext,
1368
+ projectRoot,
1369
+ wantSibling,
1370
+ verifiedHead,
1371
+ assignedRole,
1372
+ existing,
1373
+ }, deps);
1374
+ if (worktree.kind === 'stop')
1375
+ return worktree.code;
1376
+ const { spawnedWorktreePath } = worktree.value;
1259
1377
  // ----- Step 7b: provision launch access before persisting/launching -----
1260
1378
  // The launched process gets its current worktree plus a stable, disposable
1261
1379
  // per-seat scratch root. Codex also receives an external Git common directory
@@ -1327,113 +1445,16 @@ export async function runAssimilate(args, deps, options = {}) {
1327
1445
  rollbackWorktree();
1328
1446
  return 1;
1329
1447
  }
1330
- // Local-server authority: drive the COMPOSITE cube-owned FINALIZE (Race 2).
1331
- // The cube lock is held OUTER across revalidate → binding-write → activate; the
1332
- // typed expectation is declared HERE at the orchestration layer (reattach =
1333
- // EXACT prior binding with its live-bearer digest; eviction remint = EXACT ref
1334
- // only, bearer intentionally replaced; fresh/sibling = ABSENT).
1335
- if (result.finalize === undefined || deps.finalizeServerSeat === undefined) {
1336
- deps.stderr('Local Borg server session metadata is incomplete; no connection was saved.\n');
1337
- rollbackWorktree();
1338
- return 1;
1339
- }
1340
- {
1341
- // The SAME typed expectation declared before PREPARE is revalidated again at
1342
- // FINALIZE (commit-time revalidation, ratified clause 3).
1343
- let outcome;
1344
- try {
1345
- outcome = await deps.finalizeServerSeat({
1346
- active: activeCube,
1347
- commonDir: repositoryContext.commonDir,
1348
- ...(repositoryContext.publicRepository
1349
- ? { repositoryOrigin: repositoryContext.publicRepository.value }
1350
- : {}),
1351
- expected: sessionExpected,
1352
- activate: result.finalize.activate,
1353
- scrubPending: result.finalize.scrubPending,
1354
- });
1355
- }
1356
- catch (err) {
1357
- // A BINDING-WRITE (or revalidate) failure BEFORE the binding landed. Nothing
1358
- // owns the spawned worktree yet, so rolling it back is safe.
1359
- const message = err instanceof Error ? err.message : String(err);
1360
- deps.stderr(`finalizeServerSeat failed: ${message}\n`);
1361
- rollbackWorktree();
1362
- return 1;
1363
- }
1364
- if (!outcome.committed) {
1365
- if (outcome.reason === 'activation-failed') {
1366
- // CR #5: the atomic activate+bind did NOT commit (missing/replaced/threw), so
1367
- // the record stays PENDING with no worktree of its own. CR#2/CR#4: bind that
1368
- // exact pending record to THIS preserved worktree WITHOUT activating it — the
1369
- // record stays pending (non-hydratable) but becomes DISCOVERABLE from here, so
1370
- // a rerun FROM this worktree re-derives the exact original operation and
1371
- // re-sends the identical bearer, converging on the SAME seat (no ghost).
1372
- //
1373
- // CR#4 (SR-seven false-success revocation): the bindPending OUTCOME is
1374
- // load-bearing and must be BRANCHED. A blanket "safe to re-run / identical
1375
- // seat reused" claim on a missing/replaced/thrown bind is a FALSE-SUCCESS
1376
- // revocation failure — the worktree would NOT own a durable locator, yet the
1377
- // operator would be told convergence is guaranteed. Preserve the spawned
1378
- // worktree ONLY when it owns a durable locator (a `bound` outcome).
1379
- let bindOutcome = 'unavailable';
1380
- if (result.finalize?.bindPending) {
1381
- try {
1382
- bindOutcome = (await result.finalize.bindPending({
1383
- worktree: deps.findProjectRoot(deps.cwd()),
1384
- name: activeCube.name,
1385
- droneLabel: activeCube.droneLabel,
1386
- ...(activeCube.roleName !== undefined ? { roleName: activeCube.roleName } : {}),
1387
- ...(activeCube.roleClass !== undefined ? { roleClass: activeCube.roleClass } : {}),
1388
- ...(activeCube.isHumanSeat !== undefined ? { isHumanSeat: activeCube.isHumanSeat } : {}),
1389
- }));
1390
- }
1391
- catch {
1392
- bindOutcome = 'threw';
1393
- }
1394
- }
1395
- if (bindOutcome === 'bound') {
1396
- // The worktree now owns a durable locator (the bound-pending record points
1397
- // here). PRESERVE it. Truthful convergence copy: a rerun FROM here re-sends
1398
- // the identical bearer (no duplicate), and `reset-local-connection` from here now
1399
- // discovers + clears the bound-pending record.
1400
- deps.stderr(`This worktree's secure session on ${auth.apiUrl} did not finish activating, but ` +
1401
- 'its resumable connection state was PRESERVED here. This worktree was NOT removed. From ' +
1402
- `here, re-run ${localAssimilateCommand(auth.apiUrl)} to converge (the identical connection ` +
1403
- `is reused — no duplicate is minted), or run ${resetLocalSeatCommand(auth.apiUrl)} to ` +
1404
- 'clear it.\n');
1405
- return 1;
1406
- }
1407
- // missing / replaced / threw / unavailable: the worktree owns NO durable
1408
- // locator. The server may already have accepted the seat, while the client
1409
- // has no protocol operation id or cleanup endpoint with which to prove reuse
1410
- // or remove it. State the exact local outcome and do not prescribe a retry
1411
- // that can silently create a duplicate server seat (#35).
1412
- const bindFailure = bindOutcome === 'missing'
1413
- ? 'the exact pending connection record went missing locally before it could be bound'
1414
- : bindOutcome === 'replaced'
1415
- ? 'the exact pending connection record was replaced locally before it could be bound; the replacement was left untouched'
1416
- : bindOutcome === 'threw'
1417
- ? 'the private store could not be read or written while preserving the pending connection'
1418
- : 'this client did not receive a pending-connection preservation handle';
1419
- deps.stderr(`This worktree's secure session on ${auth.apiUrl} did not finish activating: ` +
1420
- `${bindFailure}. The spawned worktree will be removed. No client-only command can ` +
1421
- 'prove reuse or safely clear the possibly accepted server-side drone; ask the server ' +
1422
- 'operator to inspect that drone before retrying.\n');
1423
- rollbackWorktree();
1424
- return 1;
1425
- }
1426
- // 'expectation-mismatch': the binding was NEVER written (this worktree's
1427
- // saved seat changed under us between PREPARE and FINALIZE — a concurrent
1428
- // reset or enroll). The composite scrubbed only our own pending record — no
1429
- // orphan ACTIVE credential — so a just-spawned worktree is safe to remove.
1430
- deps.stderr(`This worktree's saved connection to ${auth.apiUrl} changed during attach ` +
1431
- '(a concurrent reset or enroll); no drone was created and nothing was overwritten. ' +
1432
- `Re-run ${localAssimilateCommand(auth.apiUrl)} to attach against the current state.\n`);
1433
- rollbackWorktree();
1434
- return 1;
1435
- }
1436
- }
1448
+ const finalization = await finalizeAssimilationSeat({
1449
+ activeCube,
1450
+ apiUrl: auth.apiUrl,
1451
+ repositoryContext,
1452
+ result,
1453
+ sessionExpected,
1454
+ rollbackWorktree,
1455
+ }, deps);
1456
+ if (finalization.kind === 'stop')
1457
+ return finalization.code;
1437
1458
  if (repositoryContext.publicRepository && deps.hasActiveSeatInDifferentCloneFamily) {
1438
1459
  try {
1439
1460
  if (await deps.hasActiveSeatInDifferentCloneFamily(result.cube_id, repositoryContext.publicRepository.value, repositoryContext.commonDir)) {
@@ -1490,193 +1511,22 @@ export async function runAssimilate(args, deps, options = {}) {
1490
1511
  });
1491
1512
  if (options.launch === false)
1492
1513
  return 0;
1493
- // ----- Step 8: Launch selected agent CLI -----
1494
- // Mirrors the kickoff invocation from claude.ts (no-args path): the agent
1495
- // picks up the newly-persisted ActiveCube via the MCP stdio server on
1496
- // startup. The kickoff prompt re-enters /loop borg_regen so the new
1497
- // drone bootstraps into the cube cleanly. The monitor clause (CR-PE-F1)
1498
- // arms the inbox tail so the new drone wakes on peer log entries in
1499
- // real time — without this, drones miss real-time wake events during
1500
- // the bootstrap window and only self-heal at the /loop heartbeat.
1501
- deps.setTerminalTitle(result.drone_label, cubeDetail.name);
1502
- // Pedagogical hint to stdout before Claude takes over the terminal.
1503
- // Ink does not enter alt-screen-buffer (verified empirically via PTY
1504
- // probe 2026-05-19), so lines printed here remain visible in the
1505
- // user's terminal scrollback above Claude's interactive UI. Color is
1506
- // gated on TTY + NO_COLOR/CI env-var conventions; the welcome shape
1507
- // itself is cube-agnostic so non-default templates render identically.
1508
- const useColor = deps.isTTY() && !process.env.NO_COLOR && !process.env.CI;
1509
- deps.stdout(renderAssimilationWelcome(result.drone_label, assignedRole.name, cubeDetail.name, useColor, authority.kind === 'server' ? authority.apiUrl : undefined));
1510
- // BUG-5 / v0.9.3: probe MCP readiness before launching claude so
1511
- // the launched session sees tools at startup. Non-blocking: probe
1512
- // failure surfaces a stderr warning but the launch proceeds (the
1513
- // kickoff text's ToolSearch recovery clause is the second line of
1514
- // defense).
1515
- const mcpReady = await deps.probeMcpReady();
1516
- if (!mcpReady) {
1517
- deps.stderr(`warning: borg-mcp readiness probe did not complete within the timeout; ` +
1518
- `launching ${cli} anyway — the kickoff prompt's ToolSearch fallback ` +
1519
- `will recover if the MCP server takes longer to start.\n`);
1520
- }
1521
- const inboxPath = deps.getInboxPath(result.cube_id, result.drone_id);
1522
- const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
1523
- // gh#929: shared wakePathArming + NEVER-TaskStop (unified with claude.ts —
1524
- // the two call sites previously carried divergent monitorClause strings).
1525
- const monitorClause = buildKickoffWakePathClause(cli, cli === 'claude' ? inboxPath : null, cli === 'claude' ? monitorStateRoot : null);
1526
- let codexWakePathClause;
1527
- let remoteArgs = [];
1528
- let launchArgs;
1529
- let codexSocketPath = null;
1530
- let codexServerCleanup = null;
1531
- const launchApproval = deps.resolveCliApprovals
1532
- ? await deps.resolveCliApprovals(cli, agentCwd, {
1533
- skipOverride: args.flags.noBorgApprovalOverride,
1534
- })
1535
- : { codexArgs: [] };
1536
- if (launchApproval.warning)
1537
- deps.stderr(`warning: ${launchApproval.warning}\n`);
1538
- // Temporary Claude-only model compatibility. Local/provider models are
1539
- // configured by the selected agent CLI and are never rewritten by Borg.
1540
- const modelEnv = resolveLaunchEnv(effectiveModel);
1541
- const childEnv = {
1542
- ...withAgentRuntimeEnv(process.env, cli),
1543
- ...modelEnv.set,
1544
- BORG_SESSION: '1',
1545
- [BORG_LAUNCH_CLI_ENV]: cli,
1546
- [BORG_LAUNCH_WORKTREE_ENV]: seatWorktree,
1547
- [BORG_LAUNCH_SCRATCH_ENV]: scratchRoot,
1548
- };
1549
- if (cli === 'opencode' && launchApproval.openCodePermission) {
1550
- childEnv.OPENCODE_PERMISSION = launchApproval.openCodePermission;
1551
- }
1552
- for (const key of modelEnv.unset) {
1553
- delete childEnv[key];
1554
- }
1555
- if (cli === 'codex') {
1556
- const remote = await deps.prepareCodexRemoteLaunch();
1557
- if (remote.warning) {
1558
- deps.stderr(`warning: ${remote.warning}\n`);
1559
- codexWakePathClause =
1560
- `⚠ Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg_regen manually whenever you return, and expect only fallback wakeups until relaunch.`;
1561
- }
1562
- else {
1563
- codexWakePathClause =
1564
- `Codex wake-path capability check passed: remote-control socket established for this session.`;
1565
- }
1566
- remoteArgs = remote.args;
1567
- // Codex env takes precedence over model env when there is overlap.
1568
- if (Object.keys(remote.env).length > 0) {
1569
- Object.assign(childEnv, remote.env);
1570
- }
1571
- codexSocketPath = socketPathFromRemoteArgs(remote.args);
1572
- codexServerCleanup = remote.server?.cleanup ?? null;
1573
- }
1574
- const kickoff = buildAgentKickoffPrompt({
1514
+ return launchAssimilatedAgent({
1515
+ flags: args.flags,
1516
+ result,
1517
+ cubeDetail,
1518
+ assignedRole,
1519
+ apiUrl: auth.apiUrl,
1575
1520
  cli,
1576
- codexWakeNonce,
1577
- monitorClause,
1578
- codexWakePathClause,
1579
- });
1580
- // Keep launch trust separate from the shared kickoff. OpenCode receives the
1581
- // same prompt text; its plugin adds the correlation identity to hidden
1582
- // TextPart metadata instead of argv or prompt content.
1583
- let openCodeKickoff = null;
1584
- let dronePort;
1585
- launchArgs = [kickoff];
1586
- if (cli === 'codex') {
1587
- // gh#673 P1-codex: -c overrides deliver BORG_SESSION and the selected
1588
- // CLI identity to the codex-spawned borg-mcp child (inherited env never
1589
- // reaches Codex MCP children — V2/V2b probes). Explicitly pin remote wake
1590
- // off when no socket is available, overriding legacy static configs that
1591
- // formerly used this transport marker as Codex identity.
1592
- launchArgs = [
1593
- ...codexLaunchDirectoryArgs(launchAccessPaths),
1594
- ...launchApproval.codexArgs,
1595
- ...codexBorgSessionConfigArgs(),
1596
- ...codexAgentKindConfigArgs(),
1597
- ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
1598
- ...codexStateRootConfigArgs(),
1599
- ...remoteArgs,
1600
- ...withCodexCwdArg(launchArgs, agentCwd),
1601
- ];
1602
- }
1603
- else if (cli === 'opencode') {
1604
- // OpenCode assimilate launch: start TUI with the kickoff passed via
1605
- // --prompt (auto-submits it as the first message). BORG_SESSION is
1606
- // pinned in opencode.json. An OS-selected launch-scoped port is shared
1607
- // with the MCP child for local HTTP entry injection.
1608
- dronePort = await allocateOpenCodePort();
1609
- childEnv.BORG_OPENCODE_PORT = String(dronePort);
1610
- installBorgPlugin();
1611
- const cwd = agentCwd;
1612
- openCodeKickoff = createOpenCodeLaunchKickoff(kickoff);
1613
- childEnv[OPENCODE_SERVER_USERNAME_ENV] = OPENCODE_SERVER_USERNAME;
1614
- childEnv[OPENCODE_SERVER_PASSWORD_ENV] = openCodeKickoff.apiPassword;
1615
- childEnv[BORG_OPENCODE_LAUNCH_CORRELATION_ENV] = openCodeKickoff.correlationIdentity;
1616
- launchArgs = buildOpenCodeLaunchArgs(cwd, dronePort, openCodeKickoff.prompt);
1617
- }
1618
- // gh#673 P1: mark the launched agent session as borg-launched so the
1619
- // MCP child + hook bins activate (launch-gate.ts). childEnv is the
1620
- // complete child environment (process.env + model.set, minus unset
1621
- // keys, plus BORG_SESSION + codex env). The exec seam must use it
1622
- // directly without re-merging process.env (assimilate-deps.ts).
1623
- const exitPromise = deps.exec(cli, launchArgs, agentCwd, childEnv);
1624
- if (cli === 'codex' && codexSocketPath && codexWakeNonce) {
1625
- void recordCodexWakeTarget({
1626
- deps,
1627
- cubeId: result.cube_id,
1628
- droneId: result.drone_id,
1629
- socketPath: codexSocketPath,
1630
- cwd: agentCwd,
1631
- previewNeedle: codexWakeNonce,
1632
- launchedAtSeconds: Math.floor(Date.now() / 1000),
1633
- });
1634
- }
1635
- // gh#opencode: bind to the kickoff-bearing session through OpenCode's local
1636
- // HTTP API after the TUI auto-submits --prompt. Best-effort.
1637
- if (cli === 'opencode' && openCodeKickoff) {
1638
- const launchKickoff = openCodeKickoff;
1639
- // The port is checked before spawn but cannot be reserved through
1640
- // OpenCode's own bind. The residual allocation-to-spawn race is tracked
1641
- // in client#298; this slice only establishes deterministic rendezvous.
1642
- const serverUrl = `http://127.0.0.1:${dronePort}`;
1643
- connectOpenCodeDrone({
1644
- serverUrl,
1645
- apiPassword: launchKickoff.apiPassword,
1646
- directory: agentCwd,
1647
- droneLabel: result.drone_label,
1648
- cubeName: cubeDetail.name,
1649
- launchIdentity: launchKickoff.correlationIdentity,
1650
- })
1651
- .then(() => injectInitialKickoff(launchKickoff))
1652
- .catch(() => { });
1653
- }
1654
- const exitCode = await exitPromise;
1655
- // gh#528: kill the borg-owned Codex app-server when the assimilate-launched
1656
- // session exits, so it isn't left orphaned (live → not pruned by pid liveness).
1657
- // OpenCode has no app-server to clean up.
1658
- if (codexServerCleanup) {
1659
- try {
1660
- codexServerCleanup();
1661
- }
1662
- catch {
1663
- // best-effort
1664
- }
1665
- }
1666
- // Sprint 18: when a sibling worktree was spawned, the user's shell
1667
- // returns to their original cwd after Claude exits (process.chdir
1668
- // doesn't propagate to the parent). Emit a stderr hint so they know
1669
- // how to get back into the worktree. shellEscape defangs any shell
1670
- // metachars in the path against paste-injection (drone-11 SR-LANE).
1671
- // Skip the hint when no worktree was spawned (--here / no-worktree
1672
- // flow) or when originalCwd already matches the worktree path
1673
- // (defensive against the no-op edge case drone-9 UX-LANE flagged).
1674
- if (spawnedWorktreePath && originalCwd !== spawnedWorktreePath) {
1675
- deps.stderr(`\nAgent exited. You were working in ${spawnedWorktreePath}; your shell is back in ${originalCwd}.\n` +
1676
- `To return:\n` +
1677
- ` cd ${shellEscape(spawnedWorktreePath)}\n`);
1678
- }
1679
- return exitCode;
1521
+ effectiveModel,
1522
+ agentCwd,
1523
+ seatWorktree,
1524
+ scratchRoot,
1525
+ launchAccessPaths,
1526
+ monitorStateRoot,
1527
+ spawnedWorktreePath,
1528
+ originalCwd,
1529
+ }, deps);
1680
1530
  }
1681
1531
  function renderWorktreeSteeringNote(worktreePath, wtBranch, primaryPath) {
1682
1532
  return (`\nWORKTREE STEERING: You are in worktree ${worktreePath} on branch ${wtBranch}. ` +