@nonbot/cli 0.9.3 → 0.9.4

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
  }
@@ -25,6 +25,12 @@ export function choirSessionIdOf(act) {
25
25
  ? p.choirSessionId
26
26
  : null;
27
27
  }
28
+ export function isolateOf(act) {
29
+ return payloadOf(act).isolate === true;
30
+ }
31
+ export function setIsIsolated(activations) {
32
+ return Array.isArray(activations) && activations.some(isolateOf);
33
+ }
28
34
  export function groupBySession(activations) {
29
35
  const singletons = [];
30
36
  const sets = new Map();
@@ -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 });
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
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.9.3';
1
+ export const VERSION = '0.9.4';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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",