@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.
@@ -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}`));
@@ -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 (headless) {
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
- ? `${profile.displayName} · tmux pane (in session '${tmuxSessionName}')`
207
- : profile.displayName;
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.',
@@ -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) {
@@ -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: 'request', id, tool, args };
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
- return `${head}${agentsMdPrefix}${bannerEmit}${cli} ${shellQuoteSingle(prompt)}`;
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.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.9.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.7.0",
29
+ "tsx": "^4.22.4",
30
30
  "typescript": "^5.3.0",
31
- "vitest": "^3.0.0"
31
+ "vitest": "^4.1.8"
32
32
  }
33
33
  }