@nonbot/cli 0.9.3 → 0.9.5

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.
@@ -6,7 +6,8 @@ import { checkCompletions } from '../lib/completion.js';
6
6
  import { deliverAnsweredPrompts } from '../lib/run-prompt.js';
7
7
  import { loadOrCreateMachineId, resolveMachineName } from '../lib/machine.js';
8
8
  import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
9
- import { groupBySession, launchCoordinatedSet, } from '../lib/choir/coordinated-set.js';
9
+ import { groupBySession, launchCoordinatedSet, setIsIsolated, } from '../lib/choir/coordinated-set.js';
10
+ import { IsolatedSessionTracker, reconcileAndCleanup as defaultReconcileAndCleanup, resolveBaseBranch as defaultResolveBaseBranch, } from '../lib/choir/isolated-session.js';
10
11
  import { applyPaneTitle } from '../lib/pane-title.js';
11
12
  import { installService, uninstallService } from '../lib/service.js';
12
13
  import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
@@ -193,6 +194,10 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
193
194
  const seen = new Set();
194
195
  const launchedSessions = new Set();
195
196
  const launchCoordinatedSetFn = deps.launchCoordinatedSet ?? launchCoordinatedSet;
197
+ const isolatedSessions = deps.isolatedSessions ?? new IsolatedSessionTracker();
198
+ const reconcileAndCleanupFn = deps.reconcileAndCleanup ?? defaultReconcileAndCleanup;
199
+ const resolveBaseBranchFn = deps.resolveBaseBranch ?? defaultResolveBaseBranch;
200
+ const reconcileBuildCommand = deps.reconcileBuildCommand ?? process.env.NONBOT_RECONCILE_BUILD_CMD ?? '';
196
201
  const trackedPanes = new Map();
197
202
  const killedByStop = new Set();
198
203
  const injectedPrompts = new Set();
@@ -351,6 +356,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
351
356
  const acts = body?.activations ?? [];
352
357
  const { singletons, sets } = groupBySession(acts);
353
358
  for (const [choirSessionId, setActs] of sets) {
359
+ if (!setIsIsolated(setActs)) {
360
+ for (const a of setActs)
361
+ singletons.push(a);
362
+ continue;
363
+ }
354
364
  for (const a of setActs)
355
365
  if (a?.id)
356
366
  seen.add(a.id);
@@ -396,6 +406,22 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
396
406
  catch {
397
407
  }
398
408
  }
409
+ try {
410
+ isolatedSessions.register({
411
+ choirSessionId,
412
+ sessionId: result.sessionId,
413
+ repoRoot: result.repoRoot,
414
+ baseBranch: resolveBaseBranchFn(result.repoRoot, deps.spawnSync),
415
+ buildTestCommand: reconcileBuildCommand,
416
+ panes: result.panes.map((p) => ({
417
+ activationId: p.activationId,
418
+ branch: p.branch,
419
+ worktreePath: p.worktreePath,
420
+ })),
421
+ });
422
+ }
423
+ catch {
424
+ }
399
425
  lastFailureReason = null;
400
426
  emitSummary();
401
427
  }
@@ -512,6 +538,20 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
512
538
  trackedMeta.delete(id);
513
539
  safeEmit(id, RUN_STAGE.FINISHED);
514
540
  stopHeartbeat(id, 'daemon-exit');
541
+ try {
542
+ const session = isolatedSessions.noteTerminal(id);
543
+ if (session) {
544
+ reconcileAndCleanupFn(session, {
545
+ spawnImpl: deps.spawnSync,
546
+ log,
547
+ });
548
+ }
549
+ }
550
+ catch (e) {
551
+ errLog(statusRow('⚠', 'reconcile failed', e.message, {
552
+ stream: process.stderr,
553
+ }) + '\n');
554
+ }
515
555
  }
516
556
  emitSummary();
517
557
  }
@@ -7,6 +7,7 @@ import { addWorktree as defaultAddWorktree } from './worktree.js';
7
7
  import { createHub as defaultCreateHub } from './hub.js';
8
8
  import { PROVIDER_PROFILES } from '../command-builders.js';
9
9
  import { validateRepoPath } from '../payload-validator.js';
10
+ import { resolveBaseBranch as defaultResolveBaseBranch } from './isolated-session.js';
10
11
  const defaultFs = {
11
12
  mkdirSync: (p, opts) => nodeFs.mkdirSync(p, opts),
12
13
  writeFileSync: (p, data, opts) => nodeFs.writeFileSync(p, data, opts),
@@ -25,6 +26,12 @@ export function choirSessionIdOf(act) {
25
26
  ? p.choirSessionId
26
27
  : null;
27
28
  }
29
+ export function isolateOf(act) {
30
+ return payloadOf(act).isolate === true;
31
+ }
32
+ export function setIsIsolated(activations) {
33
+ return Array.isArray(activations) && activations.some(isolateOf);
34
+ }
28
35
  export function groupBySession(activations) {
29
36
  const singletons = [];
30
37
  const sets = new Map();
@@ -116,7 +123,8 @@ export async function launchCoordinatedSet(args) {
116
123
  throw new Error('coordinated set token minting produced an empty token');
117
124
  }
118
125
  const sessionId = `choir_${sessionName}_${token.slice(0, 8)}`;
119
- const baseBranch = 'HEAD';
126
+ const resolveBaseBranchFn = deps.resolveBaseBranchImpl ?? defaultResolveBaseBranch;
127
+ const baseBranch = resolveBaseBranchFn(repoRoot);
120
128
  const hub = createHubImpl({
121
129
  sessionId,
122
130
  repoRoot,
@@ -0,0 +1,92 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { removeWorktree as defaultRemoveWorktree } from './worktree.js';
3
+ import { runReconcile as defaultRunReconcile } from './reconcile.js';
4
+ import { isValidBranch } from './names.js';
5
+ export class IsolatedSessionTracker {
6
+ sessions = new Map();
7
+ register(a) {
8
+ if (this.sessions.has(a.choirSessionId))
9
+ return;
10
+ this.sessions.set(a.choirSessionId, {
11
+ choirSessionId: a.choirSessionId,
12
+ sessionId: a.sessionId,
13
+ repoRoot: a.repoRoot,
14
+ baseBranch: a.baseBranch,
15
+ buildTestCommand: a.buildTestCommand ?? '',
16
+ panes: a.panes,
17
+ pending: new Set(a.panes.map((p) => p.activationId)),
18
+ });
19
+ }
20
+ tracks(activationId) {
21
+ for (const s of this.sessions.values()) {
22
+ if (s.pending.has(activationId))
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+ noteTerminal(activationId) {
28
+ for (const s of this.sessions.values()) {
29
+ if (!s.pending.delete(activationId))
30
+ continue;
31
+ if (s.pending.size === 0) {
32
+ this.sessions.delete(s.choirSessionId);
33
+ return s;
34
+ }
35
+ return null;
36
+ }
37
+ return null;
38
+ }
39
+ get size() {
40
+ return this.sessions.size;
41
+ }
42
+ }
43
+ export function resolveBaseBranch(repoRoot, spawnImpl = nodeSpawnSync) {
44
+ try {
45
+ const r = spawnImpl('git', ['-C', repoRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], {
46
+ encoding: 'utf-8',
47
+ timeout: 5000,
48
+ windowsHide: true,
49
+ });
50
+ const name = typeof r.stdout === 'string' ? r.stdout.trim() : '';
51
+ if (isValidBranch(name))
52
+ return name;
53
+ }
54
+ catch {
55
+ }
56
+ return 'main';
57
+ }
58
+ export function reconcileAndCleanup(session, deps = {}) {
59
+ const spawnImpl = deps.spawnImpl ?? nodeSpawnSync;
60
+ const runReconcileImpl = deps.runReconcileImpl ?? defaultRunReconcile;
61
+ const removeWorktreeImpl = deps.removeWorktreeImpl ?? defaultRemoveWorktree;
62
+ const log = deps.log;
63
+ const branches = session.panes.map((p) => p.branch);
64
+ const reconcile = runReconcileImpl({
65
+ repoRoot: session.repoRoot,
66
+ baseBranch: session.baseBranch,
67
+ branches,
68
+ buildTestCommand: session.buildTestCommand,
69
+ sessionId: session.sessionId,
70
+ spawnImpl,
71
+ emit: deps.emit,
72
+ });
73
+ const removed = [];
74
+ if (!reconcile.blocked) {
75
+ for (const p of session.panes) {
76
+ try {
77
+ removeWorktreeImpl({ repoRoot: session.repoRoot, worktreePath: p.worktreePath, force: true });
78
+ removed.push(p.worktreePath);
79
+ }
80
+ catch {
81
+ }
82
+ }
83
+ log?.(`[reconcile] ${session.sessionId} — merged ${reconcile.merged.length} ` +
84
+ `branch${reconcile.merged.length === 1 ? '' : 'es'}, ` +
85
+ `removed ${removed.length} worktree${removed.length === 1 ? '' : 's'}\n`);
86
+ }
87
+ else {
88
+ log?.(`[reconcile] ${session.sessionId} — BLOCKED (${reconcile.reason}); ` +
89
+ `worktrees kept for the human merge gate\n`);
90
+ }
91
+ return { reconcile, removed };
92
+ }
@@ -84,8 +84,9 @@ export function listWorktrees(repoRoot, spawnImpl = nodeSpawnSync) {
84
84
  return entries;
85
85
  }
86
86
  export function removeWorktree(args) {
87
- const { repoRoot, worktreePath, spawnImpl = nodeSpawnSync } = args;
88
- const r = runGit(spawnImpl, repoRoot, ['worktree', 'remove', worktreePath]);
87
+ const { repoRoot, worktreePath, spawnImpl = nodeSpawnSync, force = false } = args;
88
+ const flags = force ? ['--force'] : [];
89
+ const r = runGit(spawnImpl, repoRoot, ['worktree', 'remove', ...flags, worktreePath]);
89
90
  assertOk(r, 'worktree remove');
90
91
  }
91
92
  export function ensureChoirGitignored(repoRoot, fsImpl = defaultFs) {
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.9.3';
1
+ export const VERSION = '0.9.5';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "type": "module",
5
5
  "description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
6
6
  "license": "UNLICENSED",