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.
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/assimilate-cmd.d.ts +115 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +611 -761
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js.map +1 -1
- package/dist/server-handshake.d.ts +5 -5
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +3 -3
- package/dist/server-handshake.js.map +1 -1
- package/docs/RELEASING.md +2 -2
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +1108 -1056
- package/src/opencode-drone.ts +1 -0
- package/src/server-handshake.ts +5 -5
package/dist/assimilate-cmd.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
288
|
-
if (!
|
|
289
|
-
deps.stderr(
|
|
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
|
-
|
|
357
|
-
prefetchedArtifact = undefined;
|
|
358
|
-
return 1;
|
|
781
|
+
return { kind: 'stop', code: 1 };
|
|
359
782
|
}
|
|
360
783
|
let pendingForHost = false;
|
|
361
|
-
if (deps.peekPendingServerEnrollment
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (resumed)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
540
|
-
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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}. ` +
|