@nonbot/cli 0.7.1 → 0.9.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @nonbot/cli changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ - **Choir collapses into Run.** Launching multiple coordinated agents now comes from the canvas (select stories -> "Run together"), not a terminal command. The daemon groups activations that share a coordinated-set id and launches them with a git worktree per agent + the coordination MCP wired in, each pane on its own per-pane provider (e.g. 2 Claude + 1 Gemini). remains as internal plumbing only.
6
+ - The set spawn is hardened: repo path validated daemon-side, command written to a 0700 temp script (not an inline shell string), all panes asserted to share one repo, and the plan-not-API unset guard applies.
7
+
8
+ ## 0.8.0
9
+
10
+ - **Conductor: Choir runs headless, panes appear only when needed.** `nonbot choir` no longer opens N terminal panes. Each agent runs headless in its own git worktree; the live radar is the web dashboard at /choir. A tmux pane is summoned (kill + `claude --resume` in the same worktree) ONLY when an agent hits awaiting-input or an escalated stall, and torn back down when resolved — so the steady state is zero extra windows. A tunable concurrency cap (`choir.maxActiveAgents`, default 2) keeps only K agents working at once.
11
+ - **Runs light up the dashboard.** A ▶ Run now emits sanitized, metadata-only progress + a heartbeat to /choir (it is a 1-pane Choir), inheriting health/stall detection, the Needs-You queue, the dead-man's-switch and the digest — fire-and-forget, never affecting the Run itself.
12
+ - Agents run on your Claude plan, not a metered API key (carried from 0.7.1; the headless + summon spawns both unset ANTHROPIC_API_KEY/AUTH_TOKEN).
13
+
3
14
  ## 0.7.1
4
15
 
5
16
  - **Agents run on your Claude plan, not a metered API key.** The spawned run
@@ -5,6 +5,9 @@ import { header, kvRow, statusRow, statusBadge, c } from '../lib/output.js';
5
5
  import { assertValidName } from '../lib/choir/names.js';
6
6
  import { launchChoir, } from '../lib/choir/launcher.js';
7
7
  import { createHub } from '../lib/choir/hub.js';
8
+ import { planConductorTick, DEFAULT_MAX_ACTIVE_AGENTS, } from '../lib/choir/conductor.js';
9
+ import { summonPane, dematerialize } from '../lib/choir/materialize.js';
10
+ import { resolveThresholds } from '../lib/choir/health.js';
8
11
  export function buildStartHub(repoRoot, auth) {
9
12
  return ({ sessionId, baseBranch, token }) => {
10
13
  const opts = {
@@ -22,6 +25,133 @@ export function buildStartHub(repoRoot, auth) {
22
25
  return hub;
23
26
  };
24
27
  }
28
+ const TERMINAL_STATES = new Set(['complete', 'failed']);
29
+ function buildSignals(agents, panes, now) {
30
+ const byName = new Map();
31
+ for (const p of Object.values(panes)) {
32
+ if (p && typeof p === 'object') {
33
+ const rec = p;
34
+ const name = typeof rec.name === 'string' ? rec.name : null;
35
+ if (name)
36
+ byName.set(name, rec);
37
+ }
38
+ }
39
+ const out = {};
40
+ for (const a of agents) {
41
+ const rec = byName.get(a.paneName);
42
+ if (!rec)
43
+ continue;
44
+ const num = (v, fallback) => typeof v === 'number' ? v : fallback;
45
+ out[a.paneName] = {
46
+ lastToolUse: num(rec.lastToolUse, now),
47
+ lastGitDelta: num(rec.lastGitDelta, now),
48
+ lastPaneActivity: num(rec.lastPaneActivity, now),
49
+ awaitingInput: rec.status === 'awaiting-input',
50
+ crashed: rec.status === 'failed',
51
+ };
52
+ }
53
+ return out;
54
+ }
55
+ function allTerminal(agents) {
56
+ return agents.every((a) => TERMINAL_STATES.has(a.state));
57
+ }
58
+ export function runConductorLoop(args, deps) {
59
+ const { agents, hub, cap } = args;
60
+ const thresholds = deps.thresholds ?? resolveThresholds('normal');
61
+ const tickMs = deps.tickMs ?? 5000;
62
+ let cancel = null;
63
+ let tornDown = false;
64
+ const teardown = async () => {
65
+ if (tornDown)
66
+ return;
67
+ tornDown = true;
68
+ if (cancel) {
69
+ try {
70
+ cancel();
71
+ }
72
+ catch {
73
+ }
74
+ }
75
+ for (const a of agents) {
76
+ if (a.state === 'summoned' && a.tmuxPaneId != null) {
77
+ try {
78
+ await deps.dematerializeImpl(a);
79
+ }
80
+ catch {
81
+ }
82
+ a.tmuxPaneId = null;
83
+ }
84
+ }
85
+ try {
86
+ hub.stop();
87
+ }
88
+ catch {
89
+ }
90
+ logWorktreeNote();
91
+ };
92
+ const logWorktreeNote = () => {
93
+ deps.log('\n' + c.muted('worktrees kept (review before removing — unmerged work is never auto-deleted):') + '\n');
94
+ for (const a of agents) {
95
+ deps.log(` ${c.cyan(a.branch)} ${c.muted('·')} ${c.muted(a.worktreePath)}\n`);
96
+ }
97
+ };
98
+ const tick = async () => {
99
+ if (tornDown)
100
+ return;
101
+ const now = deps.now();
102
+ const signalsByPane = buildSignals(agents, hub.getState().panes, now);
103
+ const decisions = deps.planTick({
104
+ agents,
105
+ signalsByPane,
106
+ cap,
107
+ now,
108
+ thresholds,
109
+ });
110
+ for (const d of decisions) {
111
+ const a = agents.find((x) => x.paneName === d.paneName);
112
+ if (!a)
113
+ continue;
114
+ try {
115
+ switch (d.action) {
116
+ case 'start': {
117
+ deps.spawnHeadlessImpl(a);
118
+ a.state = 'headless';
119
+ break;
120
+ }
121
+ case 'summon': {
122
+ const paneId = await deps.summonPaneImpl(a);
123
+ a.tmuxPaneId = paneId;
124
+ a.state = 'summoned';
125
+ break;
126
+ }
127
+ case 'dematerialize': {
128
+ await deps.dematerializeImpl(a);
129
+ a.tmuxPaneId = null;
130
+ deps.spawnHeadlessImpl(a);
131
+ a.state = 'headless';
132
+ break;
133
+ }
134
+ case 'park':
135
+ case 'noop':
136
+ default:
137
+ break;
138
+ }
139
+ }
140
+ catch {
141
+ }
142
+ }
143
+ if (allTerminal(agents)) {
144
+ await teardown();
145
+ }
146
+ };
147
+ cancel = deps.timerImpl(() => tick(), tickMs);
148
+ if (deps.onSigint) {
149
+ deps.onSigint(() => teardown());
150
+ }
151
+ return () => {
152
+ void teardown();
153
+ };
154
+ }
25
155
  function flagValue(args, name) {
26
156
  for (let i = 0; i < args.length; i++) {
27
157
  if (args[i] === name)
@@ -72,7 +202,16 @@ export async function runChoirCommand(args = [], deps = {}) {
72
202
  return 1;
73
203
  }
74
204
  const repoRoot = process.cwd();
75
- const startHub = deps.startHubImpl ?? buildStartHub(repoRoot, auth);
205
+ const innerStartHub = deps.startHubImpl ?? buildStartHub(repoRoot, auth);
206
+ let capturedHub = null;
207
+ const startHub = innerStartHub === false
208
+ ? false
209
+ : (a) => {
210
+ const hub = innerStartHub(a);
211
+ if (hub && typeof hub === 'object')
212
+ capturedHub = hub;
213
+ return hub;
214
+ };
76
215
  let session;
77
216
  try {
78
217
  session = launch({
@@ -88,23 +227,71 @@ export async function runChoirCommand(args = [], deps = {}) {
88
227
  errLog(`✗ choir launch failed: ${e.message}\n`);
89
228
  return 1;
90
229
  }
230
+ const cap = DEFAULT_MAX_ACTIVE_AGENTS;
91
231
  const lines = [];
92
232
  lines.push(header('nonbot choir', `session ${session.sessionName} · v${VERSION}`));
93
233
  lines.push('');
94
234
  lines.push(kvRow('Repo', repoRoot));
95
235
  lines.push(kvRow('Base', session.baseBranch));
96
236
  lines.push(kvRow('Join mode', session.joinMode));
97
- lines.push(kvRow('Panes', String(session.panes.length)));
237
+ lines.push(kvRow('Agents', String(session.agents.length)));
98
238
  lines.push('');
99
- for (const p of session.panes) {
100
- lines.push(statusRow('✓', p.name, `${p.branch} ${c.muted('·')} pane ${p.paneId}`));
239
+ for (const a of session.agents) {
240
+ lines.push(statusRow('✓', a.paneName, `${a.branch} ${c.muted('·')} ${a.state}`));
101
241
  }
102
242
  lines.push('');
103
243
  lines.push(statusBadge('green', 'choir live', [
104
- 'each pane is an autonomous Claude in its own worktree',
105
- 'advisory radar online choir_radar before touching shared areas',
244
+ `▶ ${session.agents.length} agents running headless · watch the radar at /choir`,
245
+ 'a pane only opens when one needs you',
246
+ `max ${cap} active at once`,
106
247
  ]));
107
248
  lines.push('');
108
249
  log(lines.join('\n') + '\n');
250
+ const runLoop = deps.runLoopImpl ?? runConductorLoop;
251
+ if (capturedHub) {
252
+ runLoop({ agents: session.agents, hub: capturedHub, cap }, {
253
+ timerImpl: (cb, ms) => {
254
+ const t = setInterval(() => void cb(), ms);
255
+ t.unref?.();
256
+ return () => clearInterval(t);
257
+ },
258
+ now: () => Date.now(),
259
+ planTick: planConductorTick,
260
+ summonPaneImpl: (a) => summonPane(a),
261
+ dematerializeImpl: (a) => dematerialize(a),
262
+ spawnHeadlessImpl: makeRespawnHeadless(session.tokenForTest),
263
+ log,
264
+ onSigint: (handler) => process.once('SIGINT', () => void handler()),
265
+ });
266
+ }
109
267
  return 0;
110
268
  }
269
+ export function buildRespawnEnv(sessionToken, agent) {
270
+ return {
271
+ ...process.env,
272
+ CHOIR_SESSION_TOKEN: sessionToken,
273
+ CHOIR_PANE_ID: agent.branch,
274
+ CHOIR_PANE_NONCE: agent.nonce,
275
+ };
276
+ }
277
+ export function makeRespawnHeadless(sessionToken, spawnImpl) {
278
+ return (agent) => {
279
+ const safeWt = agent.worktreePath.replace(/'/g, `'\\''`);
280
+ const command = `unset ANTHROPIC_API_KEY; unset ANTHROPIC_AUTH_TOKEN; ` +
281
+ `cd '${safeWt}' && claude --resume '${agent.resumeSessionId.replace(/'/g, `'\\''`)}'`;
282
+ const env = buildRespawnEnv(sessionToken, agent);
283
+ if (spawnImpl) {
284
+ const { pid } = spawnImpl({ command, cwd: agent.worktreePath, env });
285
+ agent.headlessPid = typeof pid === 'number' ? pid : null;
286
+ return;
287
+ }
288
+ void import('node:child_process').then(({ spawn }) => {
289
+ const proc = spawn('bash', ['-c', command], {
290
+ cwd: agent.worktreePath,
291
+ env,
292
+ stdio: ['ignore', 'pipe', 'pipe'],
293
+ });
294
+ agent.headlessPid = typeof proc.pid === 'number' ? proc.pid : null;
295
+ });
296
+ };
297
+ }
@@ -3,6 +3,8 @@ import { loadAuth, getActiveProfile } from '../lib/auth.js';
3
3
  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
+ import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
7
+ import { groupBySession, launchCoordinatedSet, } from '../lib/choir/coordinated-set.js';
6
8
  import { applyPaneTitle } from '../lib/pane-title.js';
7
9
  import { installService, uninstallService } from '../lib/service.js';
8
10
  import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
@@ -131,8 +133,40 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
131
133
  applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
132
134
  }
133
135
  const seen = new Set();
136
+ const launchedSessions = new Set();
137
+ const launchCoordinatedSetFn = deps.launchCoordinatedSet ?? launchCoordinatedSet;
134
138
  const trackedPanes = new Map();
135
139
  const killedByStop = new Set();
140
+ const emitRunStageFn = deps.emitRunStage ?? defaultEmitRunStage;
141
+ const startRunHeartbeatFn = deps.startRunHeartbeat ?? defaultStartRunHeartbeat;
142
+ const runHeartbeats = new Map();
143
+ const safeEmit = (activationId, stage, metrics) => {
144
+ try {
145
+ void Promise.resolve(emitRunStageFn({ baseUrl: auth.baseUrl, pat: auth.pat, activationId, stage, metrics })).catch(() => { });
146
+ }
147
+ catch {
148
+ }
149
+ };
150
+ const stopHeartbeat = (activationId, reason) => {
151
+ const hb = runHeartbeats.get(activationId);
152
+ if (!hb)
153
+ return;
154
+ runHeartbeats.delete(activationId);
155
+ try {
156
+ void Promise.resolve(hb.stop(reason)).catch(() => { });
157
+ }
158
+ catch {
159
+ }
160
+ };
161
+ const seqMetrics = (act) => {
162
+ const p = (act.payload ?? {});
163
+ const m = {};
164
+ if (typeof p.storyIndex === 'number')
165
+ m.storyIndex = p.storyIndex;
166
+ if (typeof p.storyTotal === 'number')
167
+ m.storyTotal = p.storyTotal;
168
+ return m.storyIndex !== undefined || m.storyTotal !== undefined ? m : undefined;
169
+ };
136
170
  const trackedMeta = new Map();
137
171
  let doneCount = 0;
138
172
  let failedCount = 0;
@@ -150,6 +184,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
150
184
  if (!options.oneShot) {
151
185
  sigHandler = () => {
152
186
  running = false;
187
+ for (const id of [...runHeartbeats.keys()])
188
+ stopHeartbeat(id);
153
189
  log('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
154
190
  process.exit(0);
155
191
  };
@@ -243,15 +279,79 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
243
279
  killedByStop.add(k.activationId);
244
280
  const meta = trackedMeta.get(k.activationId);
245
281
  retitlePane('stopping', k.tmuxPaneId, meta?.story ?? '');
282
+ safeEmit(k.activationId, RUN_STAGE.STOPPED);
283
+ stopHeartbeat(k.activationId);
246
284
  }
247
285
  void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
248
286
  }
249
287
  const acts = body?.activations ?? [];
250
- for (const act of acts) {
288
+ const { singletons, sets } = groupBySession(acts);
289
+ for (const [choirSessionId, setActs] of sets) {
290
+ for (const a of setActs)
291
+ if (a?.id)
292
+ seen.add(a.id);
293
+ if (launchedSessions.has(choirSessionId))
294
+ continue;
295
+ launchedSessions.add(choirSessionId);
296
+ firedThisPoll = true;
297
+ for (const a of setActs) {
298
+ if (a.kind === 'real')
299
+ safeEmit(a.id, RUN_STAGE.LAUNCHING, seqMetrics(a));
300
+ }
301
+ try {
302
+ const result = await launchCoordinatedSetFn({
303
+ choirSessionId,
304
+ activations: setActs,
305
+ auth,
306
+ deps: deps.coordinatedSetDeps,
307
+ });
308
+ for (const pane of result.panes) {
309
+ const a = setActs.find((x) => x.id === pane.activationId);
310
+ const metrics = a && a.kind === 'real' ? seqMetrics(a) : undefined;
311
+ if (pane.tmuxPaneId) {
312
+ trackedPanes.set(pane.activationId, pane.tmuxPaneId);
313
+ trackedMeta.set(pane.activationId, {
314
+ story: pane.paneName,
315
+ provider: pane.provider,
316
+ startedAt: Date.now(),
317
+ paneId: pane.tmuxPaneId,
318
+ });
319
+ retitlePane('running', pane.tmuxPaneId, pane.paneName);
320
+ }
321
+ safeEmit(pane.activationId, RUN_STAGE.AGENT_STARTED, metrics);
322
+ try {
323
+ const hb = startRunHeartbeatFn({
324
+ baseUrl: auth.baseUrl,
325
+ pat: auth.pat,
326
+ activationId: pane.activationId,
327
+ furthestStage: RUN_STAGE.AGENT_STARTED,
328
+ lastEventSeq: 0,
329
+ });
330
+ runHeartbeats.set(pane.activationId, hb);
331
+ }
332
+ catch {
333
+ }
334
+ }
335
+ emitSummary();
336
+ }
337
+ catch (e) {
338
+ errLog(statusRow('⚠', 'coordinated set failed', e.message, { stream: process.stderr }) + '\n');
339
+ for (const a of setActs) {
340
+ if (a.kind === 'real')
341
+ safeEmit(a.id, RUN_STAGE.FAILED, seqMetrics(a));
342
+ failedCount++;
343
+ }
344
+ emitSummary();
345
+ }
346
+ }
347
+ for (const act of singletons) {
251
348
  if (!act?.id || seen.has(act.id))
252
349
  continue;
253
350
  seen.add(act.id);
254
351
  firedThisPoll = true;
352
+ const metrics = act.kind === 'real' ? seqMetrics(act) : undefined;
353
+ if (act.kind === 'real')
354
+ safeEmit(act.id, RUN_STAGE.LAUNCHING, metrics);
255
355
  const outcome = await fireActivation(auth, act, deps, log, errLog);
256
356
  if (outcome.status === 'launched' && outcome.kind === 'real' && outcome.tmuxPaneId) {
257
357
  trackedPanes.set(outcome.id, outcome.tmuxPaneId);
@@ -269,10 +369,25 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
269
369
  paneId: outcome.tmuxPaneId,
270
370
  });
271
371
  retitlePane('running', outcome.tmuxPaneId, story);
372
+ safeEmit(act.id, RUN_STAGE.AGENT_STARTED, metrics);
373
+ try {
374
+ const hb = startRunHeartbeatFn({
375
+ baseUrl: auth.baseUrl,
376
+ pat: auth.pat,
377
+ activationId: outcome.id,
378
+ furthestStage: RUN_STAGE.AGENT_STARTED,
379
+ lastEventSeq: 0,
380
+ });
381
+ runHeartbeats.set(outcome.id, hb);
382
+ }
383
+ catch {
384
+ }
272
385
  emitSummary();
273
386
  }
274
387
  else if (outcome.status === 'failed') {
275
388
  failedCount++;
389
+ if (act.kind === 'real')
390
+ safeEmit(act.id, RUN_STAGE.FAILED, metrics);
276
391
  emitSummary();
277
392
  }
278
393
  }
@@ -308,6 +423,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
308
423
  for (const id of reported) {
309
424
  doneCount++;
310
425
  trackedMeta.delete(id);
426
+ safeEmit(id, RUN_STAGE.FINISHED);
427
+ stopHeartbeat(id, 'daemon-exit');
311
428
  }
312
429
  emitSummary();
313
430
  }
@@ -0,0 +1,92 @@
1
+ import { deriveHealth, shouldEscalateToPush } from './health.js';
2
+ export const DEFAULT_MAX_ACTIVE_AGENTS = 2;
3
+ const ACTIVE_STATES = new Set(['headless', 'summoned']);
4
+ const TERMINAL_STATES = new Set(['complete', 'failed']);
5
+ function isActive(state) {
6
+ return ACTIVE_STATES.has(state);
7
+ }
8
+ function isTerminal(state) {
9
+ return TERMINAL_STATES.has(state);
10
+ }
11
+ function needsHuman(health, signals, args) {
12
+ if (signals.crashed)
13
+ return 'agent-crashed';
14
+ if (health === 'awaiting-input')
15
+ return 'awaiting-input';
16
+ const escalate = shouldEscalateToPush(health, {
17
+ thresholds: args.thresholds,
18
+ paneTotal: args.total,
19
+ liveNonTerminalPanes: args.activeCount,
20
+ slowDwellMs: 0,
21
+ });
22
+ if (escalate)
23
+ return health === 'slow' ? 'stalled' : health;
24
+ return null;
25
+ }
26
+ export function planConductorTick(args) {
27
+ const { agents, signalsByPane, now, thresholds } = args;
28
+ const cap = typeof args.cap === 'number' && args.cap >= 0 ? args.cap : DEFAULT_MAX_ACTIVE_AGENTS;
29
+ let activeCount = agents.reduce((n, a) => (isActive(a.state) ? n + 1 : n), 0);
30
+ const totalNonTerminal = agents.reduce((n, a) => (isTerminal(a.state) ? n : n + 1), 0);
31
+ const decisions = [];
32
+ for (const a of agents) {
33
+ if (isTerminal(a.state)) {
34
+ decisions.push({ paneName: a.paneName, action: 'noop', reason: a.state });
35
+ continue;
36
+ }
37
+ if (a.state === 'pending')
38
+ continue;
39
+ const signals = signalsByPane[a.paneName];
40
+ if (!signals) {
41
+ decisions.push({ paneName: a.paneName, action: 'noop' });
42
+ continue;
43
+ }
44
+ const { state: health } = deriveHealth({
45
+ now,
46
+ thresholds,
47
+ lastToolUse: signals.lastToolUse,
48
+ lastGitDelta: signals.lastGitDelta,
49
+ lastPaneActivity: signals.lastPaneActivity,
50
+ awaitingInput: signals.awaitingInput,
51
+ crashed: signals.crashed,
52
+ churning: signals.churning,
53
+ loopFlips: signals.loopFlips,
54
+ commitsAdvancedInWindow: signals.commitsAdvancedInWindow,
55
+ expectSlow: signals.expectSlow,
56
+ });
57
+ const reason = needsHuman(health, signals, {
58
+ now,
59
+ thresholds,
60
+ activeCount,
61
+ total: totalNonTerminal,
62
+ });
63
+ if (a.state === 'headless') {
64
+ if (reason) {
65
+ decisions.push({ paneName: a.paneName, action: 'summon', reason });
66
+ }
67
+ else {
68
+ decisions.push({ paneName: a.paneName, action: 'noop' });
69
+ }
70
+ }
71
+ else {
72
+ if (reason) {
73
+ decisions.push({ paneName: a.paneName, action: 'noop', reason });
74
+ }
75
+ else {
76
+ decisions.push({ paneName: a.paneName, action: 'dematerialize', reason: 'resolved' });
77
+ }
78
+ }
79
+ }
80
+ for (const a of agents) {
81
+ if (a.state !== 'pending')
82
+ continue;
83
+ if (activeCount < cap) {
84
+ decisions.push({ paneName: a.paneName, action: 'start', reason: 'slot-free' });
85
+ activeCount += 1;
86
+ }
87
+ else {
88
+ decisions.push({ paneName: a.paneName, action: 'noop', reason: 'cap-reached' });
89
+ }
90
+ }
91
+ return decisions;
92
+ }
@@ -0,0 +1,207 @@
1
+ import nodeFs from 'node:fs';
2
+ import nodePath from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { assertValidName } from './names.js';
6
+ import { addWorktree as defaultAddWorktree } from './worktree.js';
7
+ import { createHub as defaultCreateHub } from './hub.js';
8
+ import { PROVIDER_PROFILES } from '../command-builders.js';
9
+ import { validateRepoPath } from '../payload-validator.js';
10
+ const defaultFs = {
11
+ mkdirSync: (p, opts) => nodeFs.mkdirSync(p, opts),
12
+ writeFileSync: (p, data, opts) => nodeFs.writeFileSync(p, data, opts),
13
+ };
14
+ const MAX_PANES = 16;
15
+ const PLAN_AUTH_GUARD = `unset ANTHROPIC_API_KEY; unset ANTHROPIC_AUTH_TOKEN`;
16
+ function defaultRandom() {
17
+ return randomBytes(24).toString('hex');
18
+ }
19
+ function payloadOf(act) {
20
+ return (act?.payload ?? {});
21
+ }
22
+ export function choirSessionIdOf(act) {
23
+ const p = payloadOf(act);
24
+ return typeof p.choirSessionId === 'string' && p.choirSessionId.length > 0
25
+ ? p.choirSessionId
26
+ : null;
27
+ }
28
+ export function groupBySession(activations) {
29
+ const singletons = [];
30
+ const sets = new Map();
31
+ for (const act of activations) {
32
+ const sid = choirSessionIdOf(act);
33
+ if (sid === null) {
34
+ singletons.push(act);
35
+ continue;
36
+ }
37
+ const arr = sets.get(sid);
38
+ if (arr)
39
+ arr.push(act);
40
+ else
41
+ sets.set(sid, [act]);
42
+ }
43
+ return { singletons, sets };
44
+ }
45
+ function resolveProvider(act) {
46
+ const raw = payloadOf(act).provider;
47
+ if (typeof raw === 'string' && raw in PROVIDER_PROFILES)
48
+ return raw;
49
+ return 'claude';
50
+ }
51
+ function deriveSessionName(choirSessionId) {
52
+ let s = String(choirSessionId)
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9-]+/g, '-')
55
+ .replace(/-+/g, '-')
56
+ .replace(/^-+|-+$/g, '')
57
+ .slice(0, 31);
58
+ if (s.length === 0 || !/^[a-z0-9]/.test(s))
59
+ s = `s${s}`.slice(0, 31);
60
+ return assertValidName(s, 'session');
61
+ }
62
+ function buildMcpJson() {
63
+ return JSON.stringify({
64
+ mcpServers: {
65
+ choir: {
66
+ command: 'nonbot',
67
+ args: ['choir-mcp'],
68
+ env: {
69
+ CHOIR_SESSION_TOKEN: '',
70
+ CHOIR_PANE_NONCE: '',
71
+ CHOIR_PANE_ID: '',
72
+ },
73
+ },
74
+ },
75
+ }, null, 2);
76
+ }
77
+ function buildPaneCommand(worktreePath, provider, storyTitle) {
78
+ const cli = PROVIDER_PROFILES[provider].realCli;
79
+ const safeWt = worktreePath.replace(/'/g, `'\\''`);
80
+ const title = storyTitle.replace(/[\r\n]+/g, ' ');
81
+ const prompt = `You are one pane of a coordinated set. Read .choir/ for your brief, ` +
82
+ `use the choir MCP radar to coordinate, then work on: ${title}`;
83
+ const safePrompt = prompt.replace(/'/g, `'\\''`);
84
+ return `${PLAN_AUTH_GUARD}; cd '${safeWt}' && ${cli} '${safePrompt}'`;
85
+ }
86
+ export async function launchCoordinatedSet(args) {
87
+ const { choirSessionId, activations, auth } = args;
88
+ const deps = args.deps ?? {};
89
+ const fsImpl = deps.fsImpl ?? defaultFs;
90
+ const randomImpl = deps.randomImpl ?? defaultRandom;
91
+ const addWorktreeImpl = deps.addWorktreeImpl ?? defaultAddWorktree;
92
+ const createHubImpl = deps.createHubImpl ?? defaultCreateHub;
93
+ const spawnPane = deps.spawnPaneImpl ?? resolveDefaultSpawnPane(auth);
94
+ if (!Array.isArray(activations) || activations.length === 0) {
95
+ throw new Error('coordinated set has no activations');
96
+ }
97
+ if (activations.length > MAX_PANES) {
98
+ throw new Error(`coordinated set exceeds ${MAX_PANES} panes`);
99
+ }
100
+ const sessionName = deriveSessionName(choirSessionId);
101
+ const repoRootRaw = typeof payloadOf(activations[0]).repoPath === 'string'
102
+ ? payloadOf(activations[0]).repoPath
103
+ : (activations[0].repoPath ?? '');
104
+ if (!repoRootRaw)
105
+ throw new Error('coordinated set activation has no repoPath');
106
+ const repoRoot = validateRepoPath(repoRootRaw);
107
+ for (const act of activations) {
108
+ const p = payloadOf(act);
109
+ const actRepo = typeof p.repoPath === 'string' ? p.repoPath : (act.repoPath ?? '');
110
+ if (actRepo !== repoRoot) {
111
+ throw new Error('coordinated set spans multiple repoPaths — a set must be one repo');
112
+ }
113
+ }
114
+ const token = randomImpl();
115
+ if (typeof token !== 'string' || token.length === 0) {
116
+ throw new Error('coordinated set token minting produced an empty token');
117
+ }
118
+ const sessionId = `choir_${sessionName}_${token.slice(0, 8)}`;
119
+ const baseBranch = 'HEAD';
120
+ const hub = createHubImpl({
121
+ sessionId,
122
+ repoRoot,
123
+ baseBranch,
124
+ token,
125
+ baseUrl: auth.baseUrl,
126
+ pat: auth.pat,
127
+ autoTimers: true,
128
+ });
129
+ hub.start();
130
+ const panes = [];
131
+ let paneIndex = 0;
132
+ for (const act of activations) {
133
+ paneIndex += 1;
134
+ const paneName = assertValidName(`pane${paneIndex}`, 'pane');
135
+ const provider = resolveProvider(act);
136
+ const story = typeof payloadOf(act).storyTitle === 'string'
137
+ ? payloadOf(act).storyTitle
138
+ : `${act.kind} activation`;
139
+ const { worktreePath, branch } = addWorktreeImpl({
140
+ repoRoot,
141
+ sessionName,
142
+ paneName,
143
+ baseBranch,
144
+ });
145
+ const nonce = randomImpl();
146
+ const mcpPath = nodePath.join(worktreePath, '.mcp.json');
147
+ fsImpl.mkdirSync(worktreePath, { recursive: true });
148
+ fsImpl.writeFileSync(mcpPath, buildMcpJson(), { mode: 0o600 });
149
+ hub.dispatch({
150
+ type: 'register',
151
+ paneId: branch,
152
+ name: paneName,
153
+ branch,
154
+ worktreePath,
155
+ nonce,
156
+ ts: Date.now(),
157
+ });
158
+ const command = buildPaneCommand(worktreePath, provider, story);
159
+ const scriptPath = nodePath.join(tmpdir(), `nonbot-choir-${sessionId}-${paneName}.sh`);
160
+ const scriptBody = `#!/bin/bash\n${command}\n`;
161
+ fsImpl.writeFileSync(scriptPath, scriptBody, { mode: 0o700 });
162
+ const env = {
163
+ ...process.env,
164
+ CHOIR_SESSION_TOKEN: token,
165
+ CHOIR_PANE_NONCE: nonce,
166
+ CHOIR_PANE_ID: branch,
167
+ NONBOT_PAT: auth.pat,
168
+ NONBOT_RUN_ID: act.id,
169
+ NONBOT_BASE_URL: auth.baseUrl,
170
+ NONBOT_ROLE: 'lead',
171
+ };
172
+ const spawnResult = (await spawnPane({ command, scriptPath, cwd: worktreePath, env, provider, activationId: act.id, paneName })) ??
173
+ {};
174
+ const tmuxPaneId = spawnResult.tmuxPaneId ?? null;
175
+ panes.push({
176
+ activationId: act.id,
177
+ paneName,
178
+ branch,
179
+ worktreePath,
180
+ provider,
181
+ tmuxPaneId,
182
+ });
183
+ }
184
+ return { choirSessionId, sessionId, repoRoot, hub, panes, tokenForTest: token };
185
+ }
186
+ function resolveDefaultSpawnPane(_auth) {
187
+ return async (a) => {
188
+ const { spawn } = await import('node:child_process');
189
+ const proc = spawn('tmux', ['new-window', '-P', '-F', '#{pane_id}', '-n', a.paneName, `bash ${shArg(a.scriptPath)}`], {
190
+ cwd: a.cwd,
191
+ env: a.env,
192
+ stdio: ['ignore', 'pipe', 'pipe'],
193
+ });
194
+ return await new Promise((resolve) => {
195
+ const chunks = [];
196
+ proc.stdout?.on('data', (c) => chunks.push(c));
197
+ proc.on('error', () => resolve({ tmuxPaneId: null }));
198
+ proc.on('exit', () => {
199
+ const out = Buffer.concat(chunks).toString('utf-8').trim().split(/\s+/)[0] ?? '';
200
+ resolve({ tmuxPaneId: /^%\d+$/.test(out) ? out : null });
201
+ });
202
+ });
203
+ };
204
+ }
205
+ function shArg(s) {
206
+ return `'${s.replace(/'/g, `'\\''`)}'`;
207
+ }
@@ -1,4 +1,3 @@
1
- import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
1
  import nodeFs from 'node:fs';
3
2
  import nodePath from 'node:path';
4
3
  import { randomBytes } from 'node:crypto';
@@ -13,32 +12,33 @@ const MAX_PANES = 16;
13
12
  function defaultRandom() {
14
13
  return randomBytes(24).toString('hex');
15
14
  }
16
- function buildPaneShell(worktreePath, briefRelPath) {
15
+ const PLAN_AUTH_GUARD = `unset ANTHROPIC_API_KEY; unset ANTHROPIC_AUTH_TOKEN`;
16
+ function buildHeadlessCommand(worktreePath, briefRelPath) {
17
17
  const safeWt = worktreePath.replace(/'/g, `'\\''`);
18
18
  const safeBrief = briefRelPath.replace(/'/g, `'\\''`);
19
19
  const prompt = `Read ${safeBrief} (your Choir brief) and start work in this worktree.`;
20
20
  const safePrompt = prompt.replace(/'/g, `'\\''`);
21
- return (`cd '${safeWt}' && claude '${safePrompt}'` +
22
- `; printf '\\n%s\\n' 'Choir pane finished — press enter to close'` +
23
- `; read _` +
24
- `; tmux kill-pane`);
21
+ return `${PLAN_AUTH_GUARD}; cd '${safeWt}' && claude '${safePrompt}'`;
25
22
  }
26
- function spawnPane(spawnImpl, shell, env) {
27
- const r = spawnImpl('tmux', ['split-window', '-P', '-F', '#{pane_id}', shell, ';', 'select-layout', 'tiled'], {
28
- encoding: 'utf-8',
29
- timeout: 15_000,
30
- windowsHide: true,
31
- env,
32
- });
33
- if (r.status !== 0) {
34
- throw new Error('choir pane spawn failed (tmux split-window)');
35
- }
36
- const out = typeof r.stdout === 'string' ? r.stdout : '';
37
- const first = out.trim().split(/\s+/)[0] ?? '';
38
- if (!/^%\d+$/.test(first)) {
39
- throw new Error('choir pane spawn produced no pane id');
40
- }
41
- return first;
23
+ function resolveDefaultSpawnHeadless() {
24
+ return (args) => {
25
+ void import('node:child_process').then(({ spawn }) => {
26
+ const proc = spawn('bash', ['-c', args.command], {
27
+ cwd: args.cwd,
28
+ env: args.env,
29
+ stdio: ['ignore', 'pipe', 'pipe'],
30
+ });
31
+ const prefix = (chunk, sink) => {
32
+ const text = chunk.toString('utf-8');
33
+ for (const line of text.split('\n')) {
34
+ if (line.length > 0)
35
+ sink.write(`[choir:${args.paneName}] ${line}\n`);
36
+ }
37
+ };
38
+ proc.stdout?.on('data', (c) => prefix(c, process.stdout));
39
+ proc.stderr?.on('data', (c) => prefix(c, process.stderr));
40
+ });
41
+ };
42
42
  }
43
43
  function buildMcpJson() {
44
44
  const obj = {
@@ -57,7 +57,8 @@ function buildMcpJson() {
57
57
  return JSON.stringify(obj, null, 2);
58
58
  }
59
59
  export function launchChoir(args) {
60
- const { repoRoot, sessionName: rawSession, paneCount, baseBranch: rawBase, joinMode, spawnImpl = nodeSpawnSync, fsImpl = defaultFs, randomImpl = defaultRandom, addWorktreeImpl = defaultAddWorktree, startHub, } = args;
60
+ const { repoRoot, sessionName: rawSession, paneCount, baseBranch: rawBase, joinMode, spawnHeadlessImpl, fsImpl = defaultFs, randomImpl = defaultRandom, addWorktreeImpl = defaultAddWorktree, startHub, } = args;
61
+ const spawnHeadless = spawnHeadlessImpl ?? resolveDefaultSpawnHeadless();
61
62
  const sessionName = assertValidName(rawSession, 'session');
62
63
  assertValidBranch(rawBase, 'base branch');
63
64
  const baseBranch = rawBase;
@@ -72,7 +73,7 @@ export function launchChoir(args) {
72
73
  throw new Error('choir token minting produced an empty token');
73
74
  }
74
75
  const sessionId = `choir_${sessionName}_${token.slice(0, 8)}`;
75
- const panes = [];
76
+ const agents = [];
76
77
  for (const paneName of paneNames) {
77
78
  const { worktreePath, branch } = addWorktreeImpl({
78
79
  repoRoot,
@@ -92,15 +93,25 @@ export function launchChoir(args) {
92
93
  }
93
94
  else {
94
95
  }
95
- const shell = buildPaneShell(worktreePath, briefRelPath);
96
+ const command = buildHeadlessCommand(worktreePath, briefRelPath);
96
97
  const childEnv = {
97
98
  ...process.env,
98
99
  CHOIR_SESSION_TOKEN: token,
99
100
  CHOIR_PANE_NONCE: nonce,
100
101
  CHOIR_PANE_ID: branch,
101
102
  };
102
- const paneId = spawnPane(spawnImpl, shell, childEnv);
103
- panes.push({ name: paneName, paneId, branch, worktreePath });
103
+ spawnHeadless({ command, cwd: worktreePath, env: childEnv, paneName });
104
+ agents.push({
105
+ paneName,
106
+ worktreePath,
107
+ branch,
108
+ resumeSessionId: branch,
109
+ nonce,
110
+ provider: 'claude',
111
+ state: 'headless',
112
+ tmuxPaneId: null,
113
+ headlessPid: null,
114
+ });
104
115
  }
105
116
  if (startHub !== false) {
106
117
  const start = startHub ?? resolveDefaultStartHub();
@@ -112,7 +123,7 @@ export function launchChoir(args) {
112
123
  repoRoot,
113
124
  baseBranch,
114
125
  joinMode,
115
- panes,
126
+ agents,
116
127
  tokenForTest: token,
117
128
  };
118
129
  }
@@ -0,0 +1,97 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { isValidBranch, isValidPaneId } from './names.js';
3
+ import { shellQuoteSingle } from '../command-builders.js';
4
+ const defaultKill = (pid, signal) => {
5
+ try {
6
+ return process.kill(pid, signal);
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ };
12
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+ const PRESS_ENTER_TRAILER = `; printf '\\n%s\\n' 'Resolved — press enter to send this agent back headless'` +
14
+ `; read _` +
15
+ `; tmux kill-pane`;
16
+ function buildResumeInvocation(agent) {
17
+ const sid = shellQuoteSingle(agent.resumeSessionId);
18
+ switch (agent.provider) {
19
+ case 'claude':
20
+ return `claude --resume ${sid}`;
21
+ case 'codex': {
22
+ const brief = shellQuoteSingle('You were working in this git worktree — inspect `git status` / `git log` to see your state, then continue.');
23
+ return `codex ${brief}`;
24
+ }
25
+ case 'gemini-cli': {
26
+ const brief = shellQuoteSingle('You were working in this git worktree — inspect `git status` / `git log` to see your state, then continue.');
27
+ return `gemini ${brief}`;
28
+ }
29
+ default: {
30
+ const _never = agent.provider;
31
+ throw new Error(`choir materialize: unknown provider`);
32
+ }
33
+ }
34
+ }
35
+ function buildResumeShell(agent) {
36
+ const safeWt = shellQuoteSingle(agent.worktreePath);
37
+ const invocation = buildResumeInvocation(agent);
38
+ return (`unset ANTHROPIC_API_KEY` +
39
+ `; unset ANTHROPIC_AUTH_TOKEN` +
40
+ `; cd ${safeWt} && ${invocation}` +
41
+ PRESS_ENTER_TRAILER);
42
+ }
43
+ const SHELL_UNSAFE_RE = /[\n\r\0;`$"\\]/;
44
+ function assertSafeWorktreePath(p) {
45
+ if (typeof p !== 'string' || p.length === 0 || p.length > 1024 || SHELL_UNSAFE_RE.test(p)) {
46
+ throw new Error('choir materialize: invalid worktree path');
47
+ }
48
+ }
49
+ export async function summonPane(agent, deps = {}) {
50
+ const spawnImpl = deps.spawnImpl ?? nodeSpawnSync;
51
+ const killImpl = deps.killImpl ?? defaultKill;
52
+ const sleepImpl = deps.sleepImpl ?? defaultSleep;
53
+ const graceMs = deps.graceMs ?? 2000;
54
+ assertSafeWorktreePath(agent.worktreePath);
55
+ if (!isValidBranch(agent.branch)) {
56
+ throw new Error('choir materialize: invalid branch');
57
+ }
58
+ if (typeof agent.headlessPid === 'number') {
59
+ const pid = agent.headlessPid;
60
+ killImpl(pid, 'SIGINT');
61
+ await sleepImpl(graceMs);
62
+ killImpl(pid, 'SIGKILL');
63
+ }
64
+ const shell = buildResumeShell(agent);
65
+ const r = spawnImpl('tmux', ['split-window', '-P', '-F', '#{pane_id}', shell, ';', 'select-layout', 'tiled'], {
66
+ encoding: 'utf-8',
67
+ timeout: 15_000,
68
+ windowsHide: true,
69
+ });
70
+ if (r.status !== 0) {
71
+ throw new Error('choir summon pane spawn failed (tmux split-window)');
72
+ }
73
+ const out = typeof r.stdout === 'string' ? r.stdout : '';
74
+ const first = out.trim().split(/\s+/)[0] ?? '';
75
+ if (!/^%\d+$/.test(first)) {
76
+ throw new Error('choir summon produced no pane id');
77
+ }
78
+ return first;
79
+ }
80
+ export async function dematerialize(agent, deps = {}) {
81
+ const spawnImpl = deps.spawnImpl ?? nodeSpawnSync;
82
+ const paneId = agent.tmuxPaneId;
83
+ if (paneId == null)
84
+ return;
85
+ if (!isValidPaneId(paneId)) {
86
+ throw new Error('choir materialize: invalid pane id');
87
+ }
88
+ try {
89
+ spawnImpl('tmux', ['kill-pane', '-t', paneId], {
90
+ encoding: 'utf-8',
91
+ timeout: 2000,
92
+ windowsHide: true,
93
+ });
94
+ }
95
+ catch {
96
+ }
97
+ }
@@ -185,9 +185,10 @@ export function sanitizeForEgress(obj) {
185
185
  }
186
186
  const SECRET_RE = /\b(pat_[A-Za-z0-9_-]{4,}|sk_[A-Za-z0-9_-]{4,})/;
187
187
  const DIFF_RE = /^(diff --git |@@ |[+-]{3} )|(\n[+-])/;
188
- const PATH_RE = /(^|[\s'"])\/[\w.-]+\/[\w./-]+/;
188
+ const PATH_MULTI_SEGMENT_RE = /(^|[\s'"])\/[\w.-]+\/[\w./-]+/;
189
+ const PATH_SINGLE_SEGMENT_RE = /(^|[\s'"])\/[\w.-]{4,}/;
189
190
  function looksLikePath(s) {
190
- return PATH_RE.test(s);
191
+ return PATH_MULTI_SEGMENT_RE.test(s) || PATH_SINGLE_SEGMENT_RE.test(s);
191
192
  }
192
193
  function looksLikeDiff(s) {
193
194
  return DIFF_RE.test(s);
@@ -0,0 +1,101 @@
1
+ import { makeEvent, sanitizeForEgress, assertNoForbiddenFields } from './progress-events.js';
2
+ const X_REQUESTED_WITH = 'ConradPM-Native';
3
+ const HEARTBEAT_INTERVAL_MS = 30_000;
4
+ export const RUN_STAGE = Object.freeze({
5
+ QUEUED: 'queued',
6
+ ACCEPTED: 'accepted',
7
+ LAUNCHING: 'launching',
8
+ AGENT_STARTED: 'agent-started',
9
+ WORKING: 'working',
10
+ FINISHED: 'finished',
11
+ FAILED: 'failed',
12
+ STOPPED: 'stopped',
13
+ });
14
+ export function runLifecycleStage(phase) {
15
+ return phase;
16
+ }
17
+ export async function emitRunStage(opts) {
18
+ const fetchImpl = opts.fetchImpl ?? fetch;
19
+ const now = opts.now ?? Date.now;
20
+ let body;
21
+ try {
22
+ const ts = Math.floor(now());
23
+ const event = makeEvent({
24
+ sessionId: opts.activationId,
25
+ engine: 'run',
26
+ paneId: null,
27
+ stage: opts.stage,
28
+ summary: opts.summary ?? '',
29
+ ts,
30
+ metrics: opts.metrics,
31
+ refs: opts.refs,
32
+ }, { now, random: opts.randomImpl });
33
+ const safe = sanitizeForEgress(event);
34
+ assertNoForbiddenFields(safe);
35
+ body = JSON.stringify({ events: [safe] });
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ try {
41
+ await fetchImpl(`${opts.baseUrl}/api/cli/choir/events`, {
42
+ method: 'POST',
43
+ headers: {
44
+ Authorization: `Bearer ${opts.pat}`,
45
+ 'Content-Type': 'application/json',
46
+ 'X-Requested-With': X_REQUESTED_WITH,
47
+ },
48
+ body,
49
+ });
50
+ }
51
+ catch {
52
+ }
53
+ }
54
+ const defaultTimerImpl = {
55
+ setInterval: (h, ms) => setInterval(h, ms),
56
+ clearInterval: (handle) => clearInterval(handle),
57
+ };
58
+ export function startRunHeartbeat(opts) {
59
+ const fetchImpl = opts.fetchImpl ?? fetch;
60
+ const timerImpl = opts.timerImpl ?? defaultTimerImpl;
61
+ const beat = async (reason) => {
62
+ const body = {
63
+ sessionId: opts.activationId,
64
+ paneCount: 1,
65
+ furthestStage: opts.furthestStage,
66
+ lastEventSeq: opts.lastEventSeq,
67
+ };
68
+ if (reason)
69
+ body.last_beat_reason = reason;
70
+ try {
71
+ await fetchImpl(`${opts.baseUrl}/api/cli/choir/heartbeat`, {
72
+ method: 'POST',
73
+ headers: {
74
+ Authorization: `Bearer ${opts.pat}`,
75
+ 'Content-Type': 'application/json',
76
+ 'X-Requested-With': X_REQUESTED_WITH,
77
+ },
78
+ body: JSON.stringify(body),
79
+ });
80
+ }
81
+ catch {
82
+ }
83
+ };
84
+ const handle = timerImpl.setInterval(() => {
85
+ void beat();
86
+ }, HEARTBEAT_INTERVAL_MS);
87
+ if (handle && typeof handle.unref === 'function') {
88
+ ;
89
+ handle.unref();
90
+ }
91
+ let stopped = false;
92
+ return {
93
+ async stop(reason) {
94
+ if (stopped)
95
+ return;
96
+ stopped = true;
97
+ timerImpl.clearInterval(handle);
98
+ await beat(reason ?? 'daemon-exit');
99
+ },
100
+ };
101
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.7.1';
1
+ export const VERSION = '0.9.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
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",