@nonbot/cli 0.9.2 → 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.
@@ -4,8 +4,10 @@ import * as activations from '../lib/activations.js';
4
4
  import { fireActivation, makeHeadlessSpawner, } from '../lib/activations.js';
5
5
  import { checkCompletions } from '../lib/completion.js';
6
6
  import { deliverAnsweredPrompts } from '../lib/run-prompt.js';
7
+ import { loadOrCreateMachineId, resolveMachineName } from '../lib/machine.js';
7
8
  import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
8
- 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';
9
11
  import { applyPaneTitle } from '../lib/pane-title.js';
10
12
  import { installService, uninstallService } from '../lib/service.js';
11
13
  import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
@@ -177,6 +179,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
177
179
  }
178
180
  const detectTmux = deps.detectTmuxSession ?? detectTmuxSession;
179
181
  const tmuxSessionName = detectTmux();
182
+ const machineId = deps.machineId ?? loadOrCreateMachineId();
183
+ const machineName = resolveMachineName();
180
184
  if (tmuxSessionName) {
181
185
  applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
182
186
  }
@@ -190,6 +194,10 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
190
194
  const seen = new Set();
191
195
  const launchedSessions = new Set();
192
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 ?? '';
193
201
  const trackedPanes = new Map();
194
202
  const killedByStop = new Set();
195
203
  const injectedPrompts = new Set();
@@ -304,6 +312,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
304
312
  'X-CLI-Version': VERSION,
305
313
  'X-Terminal-Kind': terminalKind,
306
314
  'X-Terminal-Launchable': terminalLaunchable ? 'true' : 'false',
315
+ 'X-Machine-Id': machineId,
316
+ 'X-Machine-Name': machineName,
307
317
  };
308
318
  if (tmuxSessionName)
309
319
  headers['X-Tmux-Session'] = tmuxSessionName;
@@ -346,6 +356,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
346
356
  const acts = body?.activations ?? [];
347
357
  const { singletons, sets } = groupBySession(acts);
348
358
  for (const [choirSessionId, setActs] of sets) {
359
+ if (!setIsIsolated(setActs)) {
360
+ for (const a of setActs)
361
+ singletons.push(a);
362
+ continue;
363
+ }
349
364
  for (const a of setActs)
350
365
  if (a?.id)
351
366
  seen.add(a.id);
@@ -391,6 +406,22 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
391
406
  catch {
392
407
  }
393
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
+ }
394
425
  lastFailureReason = null;
395
426
  emitSummary();
396
427
  }
@@ -465,6 +496,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
465
496
  baseUrl: auth.baseUrl,
466
497
  pat: auth.pat,
467
498
  injected: injectedPrompts,
499
+ machineId,
468
500
  fetchImpl,
469
501
  spawnImpl: deps.spawnSync,
470
502
  log,
@@ -506,6 +538,20 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
506
538
  trackedMeta.delete(id);
507
539
  safeEmit(id, RUN_STAGE.FINISHED);
508
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
+ }
509
555
  }
510
556
  emitSummary();
511
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
+ }
@@ -0,0 +1,51 @@
1
+ import { homedir, hostname } from 'node:os';
2
+ import { randomUUID } from 'node:crypto';
3
+ import * as nodeFs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ const NAME_MAX = 64;
6
+ function configDir() {
7
+ const override = process.env.NONBOT_CONFIG_DIR;
8
+ if (override && override.length > 0)
9
+ return override;
10
+ return path.join(homedir(), '.config', 'nonbot');
11
+ }
12
+ export function machineFilePath(dir = configDir()) {
13
+ return path.join(dir, 'machine.json');
14
+ }
15
+ export function loadOrCreateMachineId(deps = {}) {
16
+ const fs = deps.fs ?? nodeFs;
17
+ const uuid = deps.uuid ?? randomUUID;
18
+ const dir = deps.dir ?? configDir();
19
+ const file = path.join(dir, 'machine.json');
20
+ try {
21
+ if (fs.existsSync(file)) {
22
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
23
+ if (raw && typeof raw.machineId === 'string' && raw.machineId.length > 0) {
24
+ return raw.machineId;
25
+ }
26
+ }
27
+ }
28
+ catch {
29
+ }
30
+ const machineId = uuid();
31
+ try {
32
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
33
+ fs.writeFileSync(file, JSON.stringify({ machineId, createdAt: Date.now() }), { mode: 0o600 });
34
+ }
35
+ catch {
36
+ }
37
+ return machineId;
38
+ }
39
+ export function resolveMachineName(env = process.env) {
40
+ const override = env.NONBOT_MACHINE_NAME;
41
+ if (override && override.trim().length > 0)
42
+ return override.trim().slice(0, NAME_MAX);
43
+ try {
44
+ const h = hostname();
45
+ if (h && h.length > 0)
46
+ return h.slice(0, NAME_MAX);
47
+ }
48
+ catch {
49
+ }
50
+ return 'unknown';
51
+ }
@@ -99,13 +99,14 @@ export async function reportPrompt(args) {
99
99
  export async function pollAnsweredPrompts(args) {
100
100
  const fetchImpl = args.fetchImpl ?? fetch;
101
101
  try {
102
- const res = await fetchImpl(`${args.baseUrl}/api/cli/run-prompts/answered`, {
103
- headers: {
104
- Authorization: `Bearer ${args.pat}`,
105
- 'X-Requested-With': 'ConradPM-Native',
106
- 'X-CLI-Version': VERSION,
107
- },
108
- });
102
+ const headers = {
103
+ Authorization: `Bearer ${args.pat}`,
104
+ 'X-Requested-With': 'ConradPM-Native',
105
+ 'X-CLI-Version': VERSION,
106
+ };
107
+ if (args.machineId)
108
+ headers['X-Machine-Id'] = args.machineId;
109
+ const res = await fetchImpl(`${args.baseUrl}/api/cli/run-prompts/answered`, { headers });
109
110
  if (!res.ok)
110
111
  return [];
111
112
  const data = (await res.json());
@@ -178,6 +179,7 @@ export async function deliverAnsweredPrompts(opts) {
178
179
  const answered = await pollAnsweredPrompts({
179
180
  baseUrl: opts.baseUrl,
180
181
  pat: opts.pat,
182
+ machineId: opts.machineId,
181
183
  fetchImpl: opts.fetchImpl,
182
184
  });
183
185
  if (answered.length === 0)
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.9.2';
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.2",
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",