@nonbot/cli 0.8.0 → 0.9.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/CHANGELOG.md +5 -0
- package/dist/commands/choir.js +23 -0
- package/dist/commands/daemon.js +150 -5
- package/dist/commands/run-prompt-hook.js +78 -0
- package/dist/index.js +6 -0
- package/dist/lib/activations.js +11 -4
- package/dist/lib/choir/coordinated-set.js +207 -0
- package/dist/lib/choir/hub.js +1 -1
- package/dist/lib/choir/mcp-hub-client.js +1 -1
- package/dist/lib/command-builders.js +31 -1
- package/dist/lib/payload-validator.js +48 -0
- package/dist/lib/run-prompt.js +204 -0
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 0.8.0
|
|
4
9
|
|
|
5
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.
|
package/dist/commands/choir.js
CHANGED
|
@@ -25,6 +25,26 @@ export function buildStartHub(repoRoot, auth) {
|
|
|
25
25
|
return hub;
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
+
export function registerAgentsWithHub(hub, agents, now = Date.now) {
|
|
29
|
+
const dispatch = hub?.dispatch;
|
|
30
|
+
if (typeof dispatch !== 'function')
|
|
31
|
+
return;
|
|
32
|
+
for (const a of agents) {
|
|
33
|
+
try {
|
|
34
|
+
dispatch.call(hub, {
|
|
35
|
+
type: 'register',
|
|
36
|
+
paneId: a.branch,
|
|
37
|
+
name: a.paneName,
|
|
38
|
+
branch: a.branch,
|
|
39
|
+
worktreePath: a.worktreePath,
|
|
40
|
+
nonce: a.nonce,
|
|
41
|
+
ts: now(),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
28
48
|
const TERMINAL_STATES = new Set(['complete', 'failed']);
|
|
29
49
|
function buildSignals(agents, panes, now) {
|
|
30
50
|
const byName = new Map();
|
|
@@ -227,6 +247,9 @@ export async function runChoirCommand(args = [], deps = {}) {
|
|
|
227
247
|
errLog(`✗ choir launch failed: ${e.message}\n`);
|
|
228
248
|
return 1;
|
|
229
249
|
}
|
|
250
|
+
if (capturedHub) {
|
|
251
|
+
registerAgentsWithHub(capturedHub, session.agents);
|
|
252
|
+
}
|
|
230
253
|
const cap = DEFAULT_MAX_ACTIVE_AGENTS;
|
|
231
254
|
const lines = [];
|
|
232
255
|
lines.push(header('nonbot choir', `session ${session.sessionName} · v${VERSION}`));
|
package/dist/commands/daemon.js
CHANGED
|
@@ -3,7 +3,9 @@ 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 { deliverAnsweredPrompts } from '../lib/run-prompt.js';
|
|
6
7
|
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';
|
|
7
9
|
import { applyPaneTitle } from '../lib/pane-title.js';
|
|
8
10
|
import { installService, uninstallService } from '../lib/service.js';
|
|
9
11
|
import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
|
|
@@ -12,6 +14,53 @@ import { errorBlock, statusRow, daemonOpener, daemonCloser, pollTick, activation
|
|
|
12
14
|
export const POLL_FAST_MS = 2000;
|
|
13
15
|
export const POLL_MAX_MS = 30000;
|
|
14
16
|
export const POLL_INTERVAL_MS = POLL_FAST_MS;
|
|
17
|
+
const TERMINAL_KIND_BY_PROFILE_ID = {
|
|
18
|
+
'terminal': 'terminal.app',
|
|
19
|
+
'iterm': 'iterm',
|
|
20
|
+
'iterm-tab': 'iterm',
|
|
21
|
+
'wezterm': 'wezterm',
|
|
22
|
+
'kitty': 'kitty',
|
|
23
|
+
'alacritty': 'alacritty',
|
|
24
|
+
'gnome-terminal': 'gnome-terminal',
|
|
25
|
+
};
|
|
26
|
+
export function terminalKindForProfile(profile) {
|
|
27
|
+
if (!profile)
|
|
28
|
+
return 'headless';
|
|
29
|
+
return TERMINAL_KIND_BY_PROFILE_ID[profile.id] ?? profile.id;
|
|
30
|
+
}
|
|
31
|
+
export function probeTerminalLaunchable(profile, spawnSyncImpl = nodeSpawnSync) {
|
|
32
|
+
if (!profile)
|
|
33
|
+
return true;
|
|
34
|
+
let cmd;
|
|
35
|
+
try {
|
|
36
|
+
cmd = profile.launch('/tmp/nonbot-launchable-probe').cmd;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
43
|
+
const probe = spawnSyncImpl(finder, [cmd], { encoding: 'utf-8', timeout: 1000, windowsHide: true });
|
|
44
|
+
if (typeof probe.status === 'number' && probe.status !== 0)
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const LAST_FAILURE_REASON_MAX = 256;
|
|
53
|
+
export function sanitizeFailureReason(reason) {
|
|
54
|
+
if (typeof reason !== 'string')
|
|
55
|
+
return null;
|
|
56
|
+
const cleaned = reason
|
|
57
|
+
.replace(/[^\x20-\x7e]+/g, ' ')
|
|
58
|
+
.replace(/ {2,}/g, ' ')
|
|
59
|
+
.trim();
|
|
60
|
+
if (!cleaned)
|
|
61
|
+
return null;
|
|
62
|
+
return cleaned.slice(0, LAST_FAILURE_REASON_MAX);
|
|
63
|
+
}
|
|
15
64
|
export function applyNonbotTmuxConfig(deps = {}) {
|
|
16
65
|
const spawnSync = deps.spawnSync ?? nodeSpawnSync;
|
|
17
66
|
const stdoutWrite = deps.stdoutWrite ?? ((s) => process.stdout.write(s));
|
|
@@ -131,9 +180,19 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
131
180
|
if (tmuxSessionName) {
|
|
132
181
|
applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
|
|
133
182
|
}
|
|
183
|
+
const resolveTerminalFn = deps.resolveTerminal ?? resolveTerminal;
|
|
184
|
+
const resolvedTerminalProfile = headless ? null : resolveTerminalFn(undefined);
|
|
185
|
+
const terminalKind = terminalKindForProfile(resolvedTerminalProfile);
|
|
186
|
+
const probeLaunchable = deps.probeTerminalLaunchable
|
|
187
|
+
?? ((p) => probeTerminalLaunchable(p));
|
|
188
|
+
const terminalLaunchable = probeLaunchable(resolvedTerminalProfile);
|
|
189
|
+
let lastFailureReason = null;
|
|
134
190
|
const seen = new Set();
|
|
191
|
+
const launchedSessions = new Set();
|
|
192
|
+
const launchCoordinatedSetFn = deps.launchCoordinatedSet ?? launchCoordinatedSet;
|
|
135
193
|
const trackedPanes = new Map();
|
|
136
194
|
const killedByStop = new Set();
|
|
195
|
+
const injectedPrompts = new Set();
|
|
137
196
|
const emitRunStageFn = deps.emitRunStage ?? defaultEmitRunStage;
|
|
138
197
|
const startRunHeartbeatFn = deps.startRunHeartbeat ?? defaultStartRunHeartbeat;
|
|
139
198
|
const runHeartbeats = new Map();
|
|
@@ -194,14 +253,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
194
253
|
? `${fixedInterval}ms fixed`
|
|
195
254
|
: `${Math.round(POLL_FAST_MS / 1000)}s -> ${Math.round(maxIntervalMs / 1000)}s adaptive`;
|
|
196
255
|
let terminalLabel;
|
|
197
|
-
if (
|
|
256
|
+
if (!resolvedTerminalProfile) {
|
|
198
257
|
terminalLabel = 'headless (inline, no terminal window)';
|
|
199
258
|
}
|
|
200
259
|
else {
|
|
201
|
-
const profile = resolveTerminal(undefined);
|
|
202
260
|
terminalLabel = tmuxSessionName
|
|
203
|
-
? `${
|
|
204
|
-
:
|
|
261
|
+
? `${resolvedTerminalProfile.displayName} · tmux pane (in session '${tmuxSessionName}')`
|
|
262
|
+
: resolvedTerminalProfile.displayName;
|
|
205
263
|
}
|
|
206
264
|
const badgeSublines = [
|
|
207
265
|
`listening on ${auth.baseUrl}`,
|
|
@@ -244,9 +302,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
244
302
|
Authorization: `Bearer ${auth.pat}`,
|
|
245
303
|
'X-Requested-With': 'ConradPM-Native',
|
|
246
304
|
'X-CLI-Version': VERSION,
|
|
305
|
+
'X-Terminal-Kind': terminalKind,
|
|
306
|
+
'X-Terminal-Launchable': terminalLaunchable ? 'true' : 'false',
|
|
247
307
|
};
|
|
248
308
|
if (tmuxSessionName)
|
|
249
309
|
headers['X-Tmux-Session'] = tmuxSessionName;
|
|
310
|
+
if (lastFailureReason)
|
|
311
|
+
headers['X-Last-Failure-Reason'] = lastFailureReason;
|
|
250
312
|
const res = await fetchImpl(`${auth.baseUrl}/api/cli/activations/pending`, {
|
|
251
313
|
headers,
|
|
252
314
|
});
|
|
@@ -282,7 +344,69 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
282
344
|
void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
|
|
283
345
|
}
|
|
284
346
|
const acts = body?.activations ?? [];
|
|
285
|
-
|
|
347
|
+
const { singletons, sets } = groupBySession(acts);
|
|
348
|
+
for (const [choirSessionId, setActs] of sets) {
|
|
349
|
+
for (const a of setActs)
|
|
350
|
+
if (a?.id)
|
|
351
|
+
seen.add(a.id);
|
|
352
|
+
if (launchedSessions.has(choirSessionId))
|
|
353
|
+
continue;
|
|
354
|
+
launchedSessions.add(choirSessionId);
|
|
355
|
+
firedThisPoll = true;
|
|
356
|
+
for (const a of setActs) {
|
|
357
|
+
if (a.kind === 'real')
|
|
358
|
+
safeEmit(a.id, RUN_STAGE.LAUNCHING, seqMetrics(a));
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const result = await launchCoordinatedSetFn({
|
|
362
|
+
choirSessionId,
|
|
363
|
+
activations: setActs,
|
|
364
|
+
auth,
|
|
365
|
+
deps: deps.coordinatedSetDeps,
|
|
366
|
+
});
|
|
367
|
+
for (const pane of result.panes) {
|
|
368
|
+
const a = setActs.find((x) => x.id === pane.activationId);
|
|
369
|
+
const metrics = a && a.kind === 'real' ? seqMetrics(a) : undefined;
|
|
370
|
+
if (pane.tmuxPaneId) {
|
|
371
|
+
trackedPanes.set(pane.activationId, pane.tmuxPaneId);
|
|
372
|
+
trackedMeta.set(pane.activationId, {
|
|
373
|
+
story: pane.paneName,
|
|
374
|
+
provider: pane.provider,
|
|
375
|
+
startedAt: Date.now(),
|
|
376
|
+
paneId: pane.tmuxPaneId,
|
|
377
|
+
});
|
|
378
|
+
retitlePane('running', pane.tmuxPaneId, pane.paneName);
|
|
379
|
+
}
|
|
380
|
+
safeEmit(pane.activationId, RUN_STAGE.AGENT_STARTED, metrics);
|
|
381
|
+
try {
|
|
382
|
+
const hb = startRunHeartbeatFn({
|
|
383
|
+
baseUrl: auth.baseUrl,
|
|
384
|
+
pat: auth.pat,
|
|
385
|
+
activationId: pane.activationId,
|
|
386
|
+
furthestStage: RUN_STAGE.AGENT_STARTED,
|
|
387
|
+
lastEventSeq: 0,
|
|
388
|
+
});
|
|
389
|
+
runHeartbeats.set(pane.activationId, hb);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
lastFailureReason = null;
|
|
395
|
+
emitSummary();
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
errLog(statusRow('⚠', 'coordinated set failed', e.message, { stream: process.stderr }) + '\n');
|
|
399
|
+
lastFailureReason = sanitizeFailureReason(e.message)
|
|
400
|
+
?? 'coordinated set launch failed';
|
|
401
|
+
for (const a of setActs) {
|
|
402
|
+
if (a.kind === 'real')
|
|
403
|
+
safeEmit(a.id, RUN_STAGE.FAILED, seqMetrics(a));
|
|
404
|
+
failedCount++;
|
|
405
|
+
}
|
|
406
|
+
emitSummary();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const act of singletons) {
|
|
286
410
|
if (!act?.id || seen.has(act.id))
|
|
287
411
|
continue;
|
|
288
412
|
seen.add(act.id);
|
|
@@ -291,6 +415,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
291
415
|
if (act.kind === 'real')
|
|
292
416
|
safeEmit(act.id, RUN_STAGE.LAUNCHING, metrics);
|
|
293
417
|
const outcome = await fireActivation(auth, act, deps, log, errLog);
|
|
418
|
+
if (outcome.status === 'launched') {
|
|
419
|
+
lastFailureReason = null;
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
lastFailureReason = sanitizeFailureReason(outcome.errorReason)
|
|
423
|
+
?? 'activation launch failed';
|
|
424
|
+
}
|
|
294
425
|
if (outcome.status === 'launched' && outcome.kind === 'real' && outcome.tmuxPaneId) {
|
|
295
426
|
trackedPanes.set(outcome.id, outcome.tmuxPaneId);
|
|
296
427
|
const p = (act.payload ?? {});
|
|
@@ -329,6 +460,20 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
329
460
|
emitSummary();
|
|
330
461
|
}
|
|
331
462
|
}
|
|
463
|
+
if (trackedPanes.size > 0) {
|
|
464
|
+
try {
|
|
465
|
+
await deliverAnsweredPrompts({
|
|
466
|
+
baseUrl: auth.baseUrl,
|
|
467
|
+
pat: auth.pat,
|
|
468
|
+
injected: injectedPrompts,
|
|
469
|
+
fetchImpl,
|
|
470
|
+
spawnImpl: deps.spawnSync,
|
|
471
|
+
log,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
}
|
|
476
|
+
}
|
|
332
477
|
if (trackedPanes.size > 0) {
|
|
333
478
|
const reported = await checkCompletions({
|
|
334
479
|
tracked: trackedPanes,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
2
|
+
import { parsePromptMenu, mintPromptId, reportPrompt, } from '../lib/run-prompt.js';
|
|
3
|
+
function defaultReadStdin() {
|
|
4
|
+
if (process.stdin.isTTY)
|
|
5
|
+
return Promise.resolve('');
|
|
6
|
+
const chunks = [];
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let done = false;
|
|
9
|
+
const finish = () => {
|
|
10
|
+
if (done)
|
|
11
|
+
return;
|
|
12
|
+
done = true;
|
|
13
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
14
|
+
};
|
|
15
|
+
process.stdin.on('data', (c) => chunks.push(c));
|
|
16
|
+
process.stdin.on('end', finish);
|
|
17
|
+
process.stdin.on('error', finish);
|
|
18
|
+
setTimeout(finish, 1500);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function capturePane(paneId, spawnSync) {
|
|
22
|
+
try {
|
|
23
|
+
const r = spawnSync('tmux', ['capture-pane', '-p', '-t', paneId], {
|
|
24
|
+
encoding: 'utf-8',
|
|
25
|
+
timeout: 2000,
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
});
|
|
28
|
+
return typeof r.stdout === 'string' ? r.stdout : '';
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function runRunPromptHookCommand(deps = {}) {
|
|
35
|
+
const env = deps.env ?? process.env;
|
|
36
|
+
const spawnSync = deps.spawnSync ?? nodeSpawnSync;
|
|
37
|
+
const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
|
|
38
|
+
const pat = env.NONBOT_PAT;
|
|
39
|
+
const baseUrl = env.NONBOT_BASE_URL;
|
|
40
|
+
const activationId = env.NONBOT_RUN_ID;
|
|
41
|
+
if (!pat || !baseUrl || !activationId)
|
|
42
|
+
return 0;
|
|
43
|
+
const paneId = typeof env.TMUX_PANE === 'string' && env.TMUX_PANE.length > 0 ? env.TMUX_PANE : null;
|
|
44
|
+
let message = '';
|
|
45
|
+
try {
|
|
46
|
+
const raw = await (deps.readStdin ?? defaultReadStdin)();
|
|
47
|
+
if (raw) {
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
if (typeof parsed?.message === 'string')
|
|
50
|
+
message = parsed.message;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
}
|
|
55
|
+
const paneText = paneId ? capturePane(paneId, spawnSync) : '';
|
|
56
|
+
const parsed = parsePromptMenu(paneText, message);
|
|
57
|
+
let question = parsed.question;
|
|
58
|
+
if (!question && parsed.options.length > 0) {
|
|
59
|
+
question = 'The agent is waiting on your input.';
|
|
60
|
+
}
|
|
61
|
+
if (!question && parsed.options.length === 0)
|
|
62
|
+
return 0;
|
|
63
|
+
const promptId = mintPromptId(activationId, paneId, { question, options: parsed.options });
|
|
64
|
+
const ok = await reportPrompt({
|
|
65
|
+
baseUrl,
|
|
66
|
+
pat,
|
|
67
|
+
activationId,
|
|
68
|
+
promptId,
|
|
69
|
+
question,
|
|
70
|
+
options: parsed.options,
|
|
71
|
+
paneId,
|
|
72
|
+
fetchImpl: deps.fetchImpl,
|
|
73
|
+
});
|
|
74
|
+
if (!ok) {
|
|
75
|
+
errLog('nonbot run-prompt-hook: prompt report failed (will retry on next notification)\n');
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runLogsCommand } from './commands/logs.js';
|
|
|
10
10
|
import { runProfilesCommand } from './commands/profiles.js';
|
|
11
11
|
import { runChoirCommand } from './commands/choir.js';
|
|
12
12
|
import { runChoirMcpCommand } from './commands/choir-mcp.js';
|
|
13
|
+
import { runRunPromptHookCommand } from './commands/run-prompt-hook.js';
|
|
13
14
|
import { setActiveProfile } from './lib/auth.js';
|
|
14
15
|
import { header, kvRow, ANSI, isTTY } from './lib/output.js';
|
|
15
16
|
const COMMANDS = [
|
|
@@ -38,6 +39,11 @@ const COMMANDS = [
|
|
|
38
39
|
description: 'Per-pane Choir MCP server over stdio. Launched by .mcp.json — not run by hand.',
|
|
39
40
|
run: () => runChoirMcpCommand(),
|
|
40
41
|
},
|
|
42
|
+
{
|
|
43
|
+
name: 'run-prompt-hook',
|
|
44
|
+
description: "Claude Code Notification-hook handler — surfaces a Run's mid-run prompt. Not run by hand.",
|
|
45
|
+
run: () => runRunPromptHookCommand(),
|
|
46
|
+
},
|
|
41
47
|
{
|
|
42
48
|
name: 'status',
|
|
43
49
|
description: 'Report login state + last daemon heartbeat.',
|
package/dist/lib/activations.js
CHANGED
|
@@ -7,13 +7,13 @@ import { resolveTerminal } from './terminal.js';
|
|
|
7
7
|
import { getActiveProfile } from './auth.js';
|
|
8
8
|
import { appendActivityLog } from './activity-log.js';
|
|
9
9
|
import { activationCard, clockTime } from './output.js';
|
|
10
|
-
import { buildCommandFromParams, shellQuoteSingle } from './command-builders.js';
|
|
10
|
+
import { buildCommandFromParams, hookSettingsPathFor, shellQuoteSingle, } from './command-builders.js';
|
|
11
11
|
import { validatePayload, validateActivationId, validateRepoPath, ValidationError, extractTerminalTheme, } from './payload-validator.js';
|
|
12
12
|
export const BUILT_COMMAND_MAX_LENGTH = 32 * 1024;
|
|
13
13
|
export const WIRE_COMMAND_MAX_LENGTH = 4096;
|
|
14
14
|
export const COMMAND_WARN_LENGTH = 8192;
|
|
15
15
|
export const COMMAND_MAX_LENGTH = 16384;
|
|
16
|
-
export function resolveExecutableCommand(act, warn = (s) => console.warn(s)) {
|
|
16
|
+
export function resolveExecutableCommand(act, warn = (s) => console.warn(s), opts = {}) {
|
|
17
17
|
if (process.env.NONBOT_TEST_DIRECT_SHELL === '1' && act.payload) {
|
|
18
18
|
const p = act.payload;
|
|
19
19
|
if (p.template === 'test-direct-shell' && typeof p.shell === 'string') {
|
|
@@ -58,6 +58,13 @@ export function resolveExecutableCommand(act, warn = (s) => console.warn(s)) {
|
|
|
58
58
|
if (typeof act.command === 'string' && act.command.length > 0 && act.command !== shell) {
|
|
59
59
|
warn(`[${act.id}] note: daemon-built command differs from server-supplied command (executing daemon-built; server text is informational).\n`);
|
|
60
60
|
}
|
|
61
|
+
if (opts.injectClaudeHook && params.template === 'real' && params.provider === 'claude') {
|
|
62
|
+
const withHook = {
|
|
63
|
+
...params,
|
|
64
|
+
hookSettingsPath: hookSettingsPathFor(params.activationId),
|
|
65
|
+
};
|
|
66
|
+
shell = buildCommandFromParams(withHook);
|
|
67
|
+
}
|
|
61
68
|
return { shell, params };
|
|
62
69
|
}
|
|
63
70
|
export function validateActivationEnvelope(act) {
|
|
@@ -201,7 +208,7 @@ async function captureSpawn(cmd, args) {
|
|
|
201
208
|
export const _captureSpawnForTests = captureSpawn;
|
|
202
209
|
export async function spawnTerminalDefault(act, auth) {
|
|
203
210
|
validateActivationEnvelope(act);
|
|
204
|
-
const resolved = resolveExecutableCommand(act);
|
|
211
|
+
const resolved = resolveExecutableCommand(act, undefined, { injectClaudeHook: true });
|
|
205
212
|
const ext = process.platform === 'darwin' ? 'command' : 'sh';
|
|
206
213
|
const scriptPath = path.join(tmpdir(), `nonbot-${act.id}.${ext}`);
|
|
207
214
|
if (path.dirname(scriptPath) !== tmpdir()) {
|
|
@@ -428,7 +435,7 @@ export async function fireActivation(auth, act, deps = {}, log = (s) => process.
|
|
|
428
435
|
reason,
|
|
429
436
|
profile: getActiveProfile(),
|
|
430
437
|
});
|
|
431
|
-
return { id: act.id, status: 'failed', kind: act.kind };
|
|
438
|
+
return { id: act.id, status: 'failed', kind: act.kind, errorReason: reason };
|
|
432
439
|
}
|
|
433
440
|
}
|
|
434
441
|
async function logActivity(entry) {
|
|
@@ -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
|
+
}
|
package/dist/lib/choir/hub.js
CHANGED
|
@@ -260,7 +260,7 @@ export function createHub(opts) {
|
|
|
260
260
|
}
|
|
261
261
|
if (msg && msg.type === 'call') {
|
|
262
262
|
const res = handleCall(ident, String(msg.tool ?? ''), String(msg.paneId ?? ''), msg.args ?? {});
|
|
263
|
-
respond(res);
|
|
263
|
+
respond({ type: 'response', id: msg.id, ...res });
|
|
264
264
|
return;
|
|
265
265
|
}
|
|
266
266
|
respond({ ok: false, error: 'unknown message type' });
|
|
@@ -98,7 +98,7 @@ export function createHubClient(opts) {
|
|
|
98
98
|
async function request(tool, args) {
|
|
99
99
|
await ensureConnected();
|
|
100
100
|
const id = nextId++;
|
|
101
|
-
const frame = { type: '
|
|
101
|
+
const frame = { type: 'call', id, tool, paneId: opts.paneId, args };
|
|
102
102
|
return new Promise((resolve, reject) => {
|
|
103
103
|
const timer = setTimeout(() => {
|
|
104
104
|
pending.delete(id);
|
|
@@ -113,6 +113,27 @@ export function buildAgentsMdHeredoc(agentsMd, activationId) {
|
|
|
113
113
|
`NONBOT_AGENTS_EOF\n` +
|
|
114
114
|
`grep -qxF .nonbot/ .gitignore 2>/dev/null || printf '%s\\n' .nonbot/ >> .gitignore`);
|
|
115
115
|
}
|
|
116
|
+
export const RUN_PROMPT_HOOK_COMMAND = 'nonbot run-prompt-hook';
|
|
117
|
+
export function hookSettingsPathFor(activationId) {
|
|
118
|
+
const safe = String(activationId || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
119
|
+
const id = safe.length > 0 ? safe : 'unknown';
|
|
120
|
+
return `.nonbot/hook-${id}.settings.json`;
|
|
121
|
+
}
|
|
122
|
+
export function buildHookSettingsJson() {
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
hooks: {
|
|
125
|
+
Notification: [
|
|
126
|
+
{ matcher: '', hooks: [{ type: 'command', command: RUN_PROMPT_HOOK_COMMAND }] },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
export function buildHookSettingsHeredoc(settingsPath) {
|
|
132
|
+
return (`mkdir -p .nonbot && ` +
|
|
133
|
+
`cat > ${settingsPath} <<'NONBOT_HOOK_EOF'\n` +
|
|
134
|
+
`${buildHookSettingsJson()}\n` +
|
|
135
|
+
`NONBOT_HOOK_EOF`);
|
|
136
|
+
}
|
|
116
137
|
export function buildDiagnosticCommand(params) {
|
|
117
138
|
const trimmed = typeof params.repoPath === 'string' ? params.repoPath.trim() : '';
|
|
118
139
|
const head = trimmed ? `cd ${shellQuoteSingle(trimmed)} && ` : '';
|
|
@@ -261,7 +282,16 @@ export function buildRealCommand(params) {
|
|
|
261
282
|
}
|
|
262
283
|
const briefPath = briefPathFor(params.activationId);
|
|
263
284
|
const prompt = `Read ${briefPath} (your per-run brief) and start work on activation ${idLabel} — story: ${safeTitleLabel}`;
|
|
264
|
-
|
|
285
|
+
const wantHook = cli === 'claude' &&
|
|
286
|
+
typeof params.hookSettingsPath === 'string' &&
|
|
287
|
+
params.hookSettingsPath.length > 0;
|
|
288
|
+
const hookHeredocPrefix = wantHook
|
|
289
|
+
? `${buildHookSettingsHeredoc(params.hookSettingsPath)} && `
|
|
290
|
+
: '';
|
|
291
|
+
const settingsFlag = wantHook
|
|
292
|
+
? `--settings ${shellQuoteSingle(params.hookSettingsPath)} `
|
|
293
|
+
: '';
|
|
294
|
+
return `${head}${agentsMdPrefix}${hookHeredocPrefix}${bannerEmit}${cli} ${settingsFlag}${shellQuoteSingle(prompt)}`;
|
|
265
295
|
}
|
|
266
296
|
export function buildCommandFromParams(params) {
|
|
267
297
|
switch (params.template) {
|
|
@@ -91,6 +91,50 @@ export function validateProvider(s) {
|
|
|
91
91
|
}
|
|
92
92
|
return s;
|
|
93
93
|
}
|
|
94
|
+
const TMUX_SAFE_DIRECTIVES = new Set([
|
|
95
|
+
'set', 'set-option', 'setw', 'set-window-option',
|
|
96
|
+
]);
|
|
97
|
+
export function isSafeTmuxConf(conf) {
|
|
98
|
+
for (const rawLine of conf.split(/\r?\n/)) {
|
|
99
|
+
const line = rawLine.trim();
|
|
100
|
+
if (line === '')
|
|
101
|
+
continue;
|
|
102
|
+
if (line.startsWith('#'))
|
|
103
|
+
continue;
|
|
104
|
+
if (line.includes('#(') || line.includes('$(') || line.includes('`'))
|
|
105
|
+
return false;
|
|
106
|
+
const firstToken = line.split(/\s+/)[0];
|
|
107
|
+
if (!TMUX_SAFE_DIRECTIVES.has(firstToken))
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
const ITERM_FORBIDDEN_KEYS = ['Command', 'Initial Text', 'Send Text at Start'];
|
|
113
|
+
export function isSafeItermProfileJson(jsonStr) {
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(jsonStr);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (!parsed || typeof parsed !== 'object')
|
|
122
|
+
return false;
|
|
123
|
+
const root = parsed;
|
|
124
|
+
const profiles = Array.isArray(root.Profiles) ? root.Profiles : [root];
|
|
125
|
+
for (const prof of profiles) {
|
|
126
|
+
if (!prof || typeof prof !== 'object')
|
|
127
|
+
return false;
|
|
128
|
+
const p = prof;
|
|
129
|
+
for (const k of ITERM_FORBIDDEN_KEYS) {
|
|
130
|
+
if (k in p)
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if ('Custom Command' in p && p['Custom Command'] !== 'No')
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
94
138
|
export function validateTerminalTheme(raw) {
|
|
95
139
|
if (raw === undefined || raw === null)
|
|
96
140
|
return null;
|
|
@@ -107,6 +151,10 @@ export function validateTerminalTheme(raw) {
|
|
|
107
151
|
return null;
|
|
108
152
|
if (typeof t.itermProfileJson !== 'string' || t.itermProfileJson.length > THEME_ITERM_JSON_MAX)
|
|
109
153
|
return null;
|
|
154
|
+
if (!isSafeTmuxConf(t.tmuxConf))
|
|
155
|
+
return null;
|
|
156
|
+
if (!isSafeItermProfileJson(t.itermProfileJson))
|
|
157
|
+
return null;
|
|
110
158
|
return {
|
|
111
159
|
id: t.id,
|
|
112
160
|
name: t.name,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
3
|
+
import { VERSION } from '../version.js';
|
|
4
|
+
const QUESTION_MAX = 200;
|
|
5
|
+
const LABEL_MAX = 80;
|
|
6
|
+
const OPTIONS_MAX = 8;
|
|
7
|
+
const MENU_LINE_RE = /^\s*[>❯▸▶*•]?\s*(\d{1,2})[.)]\s+(\S.*)$/;
|
|
8
|
+
const GLYPH_RE = /[│┃|╭╮╯╰─━┄┈┌┐└┘├┤>❯▸▶*•]/g;
|
|
9
|
+
export function parsePromptMenu(paneText, fallbackMessage = '') {
|
|
10
|
+
const fallback = (fallbackMessage || '').trim().slice(0, QUESTION_MAX);
|
|
11
|
+
if (typeof paneText !== 'string' || paneText.length === 0) {
|
|
12
|
+
return { question: fallback, options: [] };
|
|
13
|
+
}
|
|
14
|
+
const lines = paneText.split('\n');
|
|
15
|
+
let endIdx = -1;
|
|
16
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
17
|
+
if (MENU_LINE_RE.test(lines[i])) {
|
|
18
|
+
endIdx = i;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (endIdx === -1)
|
|
23
|
+
return { question: fallback, options: [] };
|
|
24
|
+
let startIdx = endIdx;
|
|
25
|
+
while (startIdx - 1 >= 0 && MENU_LINE_RE.test(lines[startIdx - 1]))
|
|
26
|
+
startIdx--;
|
|
27
|
+
const options = [];
|
|
28
|
+
for (let i = startIdx; i <= endIdx; i++) {
|
|
29
|
+
if (options.length >= OPTIONS_MAX)
|
|
30
|
+
break;
|
|
31
|
+
const m = lines[i].match(MENU_LINE_RE);
|
|
32
|
+
if (!m)
|
|
33
|
+
continue;
|
|
34
|
+
const index = Number.parseInt(m[1], 10);
|
|
35
|
+
if (!Number.isFinite(index))
|
|
36
|
+
continue;
|
|
37
|
+
const label = m[2].trim().slice(0, LABEL_MAX);
|
|
38
|
+
if (!label)
|
|
39
|
+
continue;
|
|
40
|
+
options.push({ index, label });
|
|
41
|
+
}
|
|
42
|
+
if (options.length === 0)
|
|
43
|
+
return { question: fallback, options: [] };
|
|
44
|
+
let question = '';
|
|
45
|
+
for (let i = startIdx - 1; i >= 0; i--) {
|
|
46
|
+
if (MENU_LINE_RE.test(lines[i]))
|
|
47
|
+
continue;
|
|
48
|
+
const t = lines[i].replace(GLYPH_RE, '').trim();
|
|
49
|
+
if (!t)
|
|
50
|
+
continue;
|
|
51
|
+
if (!/[A-Za-z0-9]/.test(t))
|
|
52
|
+
continue;
|
|
53
|
+
question = t;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
question = (question || fallback).slice(0, QUESTION_MAX);
|
|
57
|
+
return { question, options };
|
|
58
|
+
}
|
|
59
|
+
export function mintPromptId(activationId, paneId, parsed) {
|
|
60
|
+
const sig = JSON.stringify({
|
|
61
|
+
q: parsed.question,
|
|
62
|
+
o: parsed.options.map((o) => `${o.index}:${o.label}`),
|
|
63
|
+
});
|
|
64
|
+
const hash = createHash('sha256')
|
|
65
|
+
.update(`${activationId}|${paneId || ''}|${sig}`)
|
|
66
|
+
.digest('hex')
|
|
67
|
+
.slice(0, 24);
|
|
68
|
+
return `rp_${hash}`;
|
|
69
|
+
}
|
|
70
|
+
export async function reportPrompt(args) {
|
|
71
|
+
const fetchImpl = args.fetchImpl ?? fetch;
|
|
72
|
+
try {
|
|
73
|
+
const body = {
|
|
74
|
+
promptId: args.promptId,
|
|
75
|
+
question: args.question,
|
|
76
|
+
options: args.options,
|
|
77
|
+
};
|
|
78
|
+
if (args.paneId)
|
|
79
|
+
body.paneId = args.paneId;
|
|
80
|
+
const res = await fetchImpl(`${args.baseUrl}/api/cli/activations/${args.activationId}/prompt`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bearer ${args.pat}`,
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
'X-Requested-With': 'ConradPM-Native',
|
|
86
|
+
'X-CLI-Version': VERSION,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
});
|
|
90
|
+
return res.ok;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function pollAnsweredPrompts(args) {
|
|
97
|
+
const fetchImpl = args.fetchImpl ?? fetch;
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetchImpl(`${args.baseUrl}/api/cli/run-prompts/answered`, {
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${args.pat}`,
|
|
102
|
+
'X-Requested-With': 'ConradPM-Native',
|
|
103
|
+
'X-CLI-Version': VERSION,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
return [];
|
|
108
|
+
const data = (await res.json());
|
|
109
|
+
if (!data || !Array.isArray(data.prompts))
|
|
110
|
+
return [];
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const r of data.prompts) {
|
|
113
|
+
if (!r || typeof r.promptId !== 'string' || typeof r.activationId !== 'string')
|
|
114
|
+
continue;
|
|
115
|
+
out.push({
|
|
116
|
+
promptId: r.promptId,
|
|
117
|
+
activationId: r.activationId,
|
|
118
|
+
paneId: typeof r.paneId === 'string' && r.paneId.length > 0 ? r.paneId : null,
|
|
119
|
+
answerIndex: typeof r.answerIndex === 'number' ? r.answerIndex : null,
|
|
120
|
+
answerText: typeof r.answerText === 'string' ? r.answerText : null,
|
|
121
|
+
answeredAt: typeof r.answeredAt === 'number' ? r.answeredAt : undefined,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function confirmDelivered(args) {
|
|
131
|
+
const fetchImpl = args.fetchImpl ?? fetch;
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetchImpl(`${args.baseUrl}/api/cli/run-prompts/${args.promptId}/delivered`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${args.pat}`,
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
'X-Requested-With': 'ConradPM-Native',
|
|
139
|
+
'X-CLI-Version': VERSION,
|
|
140
|
+
},
|
|
141
|
+
body: '{}',
|
|
142
|
+
});
|
|
143
|
+
return res.ok || res.status === 409;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function injectAnswer(paneId, answerIndex, answerText, spawnImpl = nodeSpawnSync) {
|
|
150
|
+
const keys = answerIndex !== null && Number.isFinite(answerIndex)
|
|
151
|
+
? String(answerIndex)
|
|
152
|
+
: answerText ?? '';
|
|
153
|
+
if (keys.length === 0)
|
|
154
|
+
return false;
|
|
155
|
+
try {
|
|
156
|
+
spawnImpl('tmux', ['send-keys', '-l', '-t', paneId, keys], {
|
|
157
|
+
encoding: 'utf-8',
|
|
158
|
+
timeout: 2000,
|
|
159
|
+
windowsHide: true,
|
|
160
|
+
});
|
|
161
|
+
spawnImpl('tmux', ['send-keys', '-t', paneId, 'Enter'], {
|
|
162
|
+
encoding: 'utf-8',
|
|
163
|
+
timeout: 2000,
|
|
164
|
+
windowsHide: true,
|
|
165
|
+
});
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export async function deliverAnsweredPrompts(opts) {
|
|
173
|
+
const answered = await pollAnsweredPrompts({
|
|
174
|
+
baseUrl: opts.baseUrl,
|
|
175
|
+
pat: opts.pat,
|
|
176
|
+
fetchImpl: opts.fetchImpl,
|
|
177
|
+
});
|
|
178
|
+
if (answered.length === 0)
|
|
179
|
+
return [];
|
|
180
|
+
const reported = [];
|
|
181
|
+
for (const p of answered) {
|
|
182
|
+
if (!p.paneId)
|
|
183
|
+
continue;
|
|
184
|
+
if (!opts.injected.has(p.promptId)) {
|
|
185
|
+
const sent = injectAnswer(p.paneId, p.answerIndex, p.answerText, opts.spawnImpl);
|
|
186
|
+
if (!sent)
|
|
187
|
+
continue;
|
|
188
|
+
opts.injected.add(p.promptId);
|
|
189
|
+
}
|
|
190
|
+
const confirmed = await confirmDelivered({
|
|
191
|
+
baseUrl: opts.baseUrl,
|
|
192
|
+
pat: opts.pat,
|
|
193
|
+
promptId: p.promptId,
|
|
194
|
+
fetchImpl: opts.fetchImpl,
|
|
195
|
+
});
|
|
196
|
+
if (confirmed) {
|
|
197
|
+
reported.push(p.promptId);
|
|
198
|
+
opts.log?.(`✓ ${p.activationId} · answer delivered to pane ${p.paneId}\n`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (opts.injected.size > 500)
|
|
202
|
+
opts.injected.clear();
|
|
203
|
+
return reported;
|
|
204
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.9.1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nonbot/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^22.0.0",
|
|
29
|
-
"tsx": "^4.
|
|
29
|
+
"tsx": "^4.22.4",
|
|
30
30
|
"typescript": "^5.3.0",
|
|
31
|
-
"vitest": "^
|
|
31
|
+
"vitest": "^4.1.8"
|
|
32
32
|
}
|
|
33
33
|
}
|