@nonbot/cli 0.9.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/dist/commands/choir.js +23 -0
- package/dist/commands/daemon.js +87 -4
- 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/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/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,6 +3,7 @@ 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';
|
|
7
8
|
import { groupBySession, launchCoordinatedSet, } from '../lib/choir/coordinated-set.js';
|
|
8
9
|
import { applyPaneTitle } from '../lib/pane-title.js';
|
|
@@ -13,6 +14,53 @@ import { errorBlock, statusRow, daemonOpener, daemonCloser, pollTick, activation
|
|
|
13
14
|
export const POLL_FAST_MS = 2000;
|
|
14
15
|
export const POLL_MAX_MS = 30000;
|
|
15
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
|
+
}
|
|
16
64
|
export function applyNonbotTmuxConfig(deps = {}) {
|
|
17
65
|
const spawnSync = deps.spawnSync ?? nodeSpawnSync;
|
|
18
66
|
const stdoutWrite = deps.stdoutWrite ?? ((s) => process.stdout.write(s));
|
|
@@ -132,11 +180,19 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
132
180
|
if (tmuxSessionName) {
|
|
133
181
|
applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
|
|
134
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;
|
|
135
190
|
const seen = new Set();
|
|
136
191
|
const launchedSessions = new Set();
|
|
137
192
|
const launchCoordinatedSetFn = deps.launchCoordinatedSet ?? launchCoordinatedSet;
|
|
138
193
|
const trackedPanes = new Map();
|
|
139
194
|
const killedByStop = new Set();
|
|
195
|
+
const injectedPrompts = new Set();
|
|
140
196
|
const emitRunStageFn = deps.emitRunStage ?? defaultEmitRunStage;
|
|
141
197
|
const startRunHeartbeatFn = deps.startRunHeartbeat ?? defaultStartRunHeartbeat;
|
|
142
198
|
const runHeartbeats = new Map();
|
|
@@ -197,14 +253,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
197
253
|
? `${fixedInterval}ms fixed`
|
|
198
254
|
: `${Math.round(POLL_FAST_MS / 1000)}s -> ${Math.round(maxIntervalMs / 1000)}s adaptive`;
|
|
199
255
|
let terminalLabel;
|
|
200
|
-
if (
|
|
256
|
+
if (!resolvedTerminalProfile) {
|
|
201
257
|
terminalLabel = 'headless (inline, no terminal window)';
|
|
202
258
|
}
|
|
203
259
|
else {
|
|
204
|
-
const profile = resolveTerminal(undefined);
|
|
205
260
|
terminalLabel = tmuxSessionName
|
|
206
|
-
? `${
|
|
207
|
-
:
|
|
261
|
+
? `${resolvedTerminalProfile.displayName} · tmux pane (in session '${tmuxSessionName}')`
|
|
262
|
+
: resolvedTerminalProfile.displayName;
|
|
208
263
|
}
|
|
209
264
|
const badgeSublines = [
|
|
210
265
|
`listening on ${auth.baseUrl}`,
|
|
@@ -247,9 +302,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
247
302
|
Authorization: `Bearer ${auth.pat}`,
|
|
248
303
|
'X-Requested-With': 'ConradPM-Native',
|
|
249
304
|
'X-CLI-Version': VERSION,
|
|
305
|
+
'X-Terminal-Kind': terminalKind,
|
|
306
|
+
'X-Terminal-Launchable': terminalLaunchable ? 'true' : 'false',
|
|
250
307
|
};
|
|
251
308
|
if (tmuxSessionName)
|
|
252
309
|
headers['X-Tmux-Session'] = tmuxSessionName;
|
|
310
|
+
if (lastFailureReason)
|
|
311
|
+
headers['X-Last-Failure-Reason'] = lastFailureReason;
|
|
253
312
|
const res = await fetchImpl(`${auth.baseUrl}/api/cli/activations/pending`, {
|
|
254
313
|
headers,
|
|
255
314
|
});
|
|
@@ -332,10 +391,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
332
391
|
catch {
|
|
333
392
|
}
|
|
334
393
|
}
|
|
394
|
+
lastFailureReason = null;
|
|
335
395
|
emitSummary();
|
|
336
396
|
}
|
|
337
397
|
catch (e) {
|
|
338
398
|
errLog(statusRow('⚠', 'coordinated set failed', e.message, { stream: process.stderr }) + '\n');
|
|
399
|
+
lastFailureReason = sanitizeFailureReason(e.message)
|
|
400
|
+
?? 'coordinated set launch failed';
|
|
339
401
|
for (const a of setActs) {
|
|
340
402
|
if (a.kind === 'real')
|
|
341
403
|
safeEmit(a.id, RUN_STAGE.FAILED, seqMetrics(a));
|
|
@@ -353,6 +415,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
353
415
|
if (act.kind === 'real')
|
|
354
416
|
safeEmit(act.id, RUN_STAGE.LAUNCHING, metrics);
|
|
355
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
|
+
}
|
|
356
425
|
if (outcome.status === 'launched' && outcome.kind === 'real' && outcome.tmuxPaneId) {
|
|
357
426
|
trackedPanes.set(outcome.id, outcome.tmuxPaneId);
|
|
358
427
|
const p = (act.payload ?? {});
|
|
@@ -391,6 +460,20 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
391
460
|
emitSummary();
|
|
392
461
|
}
|
|
393
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
|
+
}
|
|
394
477
|
if (trackedPanes.size > 0) {
|
|
395
478
|
const reported = await checkCompletions({
|
|
396
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) {
|
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.9.
|
|
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.9.
|
|
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
|
}
|