@usenexus/cli 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ // The local server: 127.0.0.1 only, a random port, a per-launch token,
2
+ // and a strict Host + Origin check, so no other site or local page can
3
+ // drive the person's Claude Code through it.
4
+ import http from 'node:http';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import crypto from 'node:crypto';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { createHub } from './hub.mjs';
10
+ import { createProvider, PERMISSION_MODES } from './provider.mjs';
11
+ import { txFromMessage, newTxContext } from './normalize.mjs';
12
+ import { beatsFromMessage, newBeatContext, MAIN } from '../shared/beats.mjs';
13
+ import { preflight } from './preflight.mjs';
14
+ import * as repoMod from './repo.mjs';
15
+ import * as gh from './gh.mjs';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ export const PKG = path.resolve(HERE, '..');
19
+ const MAX_BODY = 1024 * 1024;
20
+
21
+ export const ADOPT_PASTE = [
22
+ 'Clone https://github.com/daretodave/nexus.git to',
23
+ "../nexus (or use ./.nexus if submoduled) if you haven't",
24
+ 'already, then read and follow ../nexus/prompts/adopt.md',
25
+ 'exactly, in order. Do not skip steps or invent scope beyond',
26
+ 'what it says.',
27
+ ].join('\n');
28
+
29
+ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json', '.woff2': 'font/woff2', '.png': 'image/png', '.ico': 'image/x-icon' };
30
+
31
+ function readPrompt(name) {
32
+ for (const dir of [path.join(PKG, 'prompts'), path.resolve(PKG, '..', '..', 'prompts')]) {
33
+ const f = path.join(dir, name);
34
+ if (fs.existsSync(f)) return fs.readFileSync(f, 'utf8');
35
+ }
36
+ return '';
37
+ }
38
+
39
+ /**
40
+ * @param {object} o
41
+ * @param {string} o.repo the adopter repo (cwd for sessions)
42
+ * @param {number} [o.port=0] 0 = any free port
43
+ * @param {string} [o.token] defaults to a fresh random token
44
+ * @param {object|function} [o.provider] inject a provider, or a factory given the hooks (tests)
45
+ * @param {boolean} [o.pollRuns=true]
46
+ */
47
+ export async function startServer({ repo, port = 0, token = crypto.randomBytes(24).toString('base64url'), provider: injected, pollRuns = true, dist = path.join(PKG, 'dist') } = {}) {
48
+ const hub = createHub();
49
+ const asks = new Map();
50
+ const permissions = new Map();
51
+ let txCtx = newTxContext();
52
+ let beatCtx = newBeatContext();
53
+ let prefs = await repoMod.readPrefs(repo);
54
+ let busy = false;
55
+
56
+ const publishAsks = () => hub.set('asks', [...asks.values()].map((a) => a.view));
57
+ const publishPerms = () => hub.set('permissions', [...permissions.values()].map((a) => a.view));
58
+
59
+ const onMessage = (msg) => {
60
+ for (const item of txFromMessage(msg, txCtx)) hub.tx(item);
61
+ for (const b of beatsFromMessage(msg, beatCtx)) hub.beat(b);
62
+ if (msg.type === 'result') {
63
+ busy = false;
64
+ hub.setStatus({ state: 'idle', cost: msg.total_cost_usd ?? null, session: msg.session_id || null });
65
+ }
66
+ };
67
+
68
+ const onAsk = ({ id, agent, questions, signal }) => new Promise((resolve) => {
69
+ const view = { id, agent, questions, at: Date.now() };
70
+ asks.set(id, { view, resolve });
71
+ // The SDK's agentID is a subagent's own id, not the tool_use id the stage
72
+ // draws it under, so stage beats attach questions to the session orb.
73
+ hub.beat({ k: 'oversight', p: 'ask', id, agent: MAIN });
74
+ publishAsks();
75
+ signal?.addEventListener('abort', () => {
76
+ if (!asks.has(id)) return;
77
+ asks.delete(id);
78
+ hub.beat({ k: 'oversight', p: 'answer', id });
79
+ publishAsks();
80
+ resolve(null);
81
+ });
82
+ });
83
+
84
+ const onPermission = ({ id, agent, tool, input, suggestions, reason, signal }) => new Promise((resolve) => {
85
+ const view = { id, agent, tool, summary: JSON.stringify(input).slice(0, 400), reason: reason || null, canAlways: !!(suggestions && suggestions.length) };
86
+ permissions.set(id, { view, resolve });
87
+ if (!agent || agent === MAIN) hub.beat({ k: 'agent', p: 'wait', id: MAIN });
88
+ publishPerms();
89
+ signal?.addEventListener('abort', () => {
90
+ if (!permissions.delete(id)) return;
91
+ publishPerms();
92
+ resolve({ allow: false });
93
+ });
94
+ });
95
+
96
+ const hooks = {
97
+ repo,
98
+ onMessage,
99
+ onAsk,
100
+ onPermission,
101
+ onStatus: (s) => { if (s.state === 'idle') busy = false; hub.setStatus(s); },
102
+ permissionMode: () => prefs.permissionMode,
103
+ };
104
+ const provider = typeof injected === 'function' ? injected(hooks) : injected || await createProvider(hooks);
105
+ hub.setStatus({ state: 'idle', provider: provider.kind });
106
+
107
+ const server = http.createServer((req, res) => {
108
+ handle(req, res).catch((e) => send(res, 500, { error: String(e && e.message ? e.message : e) }));
109
+ });
110
+ await new Promise((r) => server.listen(port, '127.0.0.1', r));
111
+ const actual = server.address().port;
112
+ const origins = new Set([`http://127.0.0.1:${actual}`, `http://localhost:${actual}`]);
113
+ const hosts = new Set([`127.0.0.1:${actual}`, `localhost:${actual}`]);
114
+
115
+ function guard(req, url) {
116
+ if (!hosts.has(req.headers.host || '')) return 'bad host';
117
+ const origin = req.headers.origin;
118
+ if (origin && !origins.has(origin)) return 'bad origin';
119
+ if (req.method !== 'GET' && !origin) return 'missing origin';
120
+ const given = req.headers['x-nexus-token'] || url.searchParams.get('t') || '';
121
+ const a = Buffer.from(String(given));
122
+ const b = Buffer.from(token);
123
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return 'bad token';
124
+ return null;
125
+ }
126
+
127
+ async function body(req) {
128
+ if (!/^application\/json\b/.test(req.headers['content-type'] || '')) throw Object.assign(new Error('json only'), { status: 415 });
129
+ let size = 0;
130
+ const chunks = [];
131
+ for await (const c of req) {
132
+ size += c.length;
133
+ if (size > MAX_BODY) throw Object.assign(new Error('too large'), { status: 413 });
134
+ chunks.push(c);
135
+ }
136
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { throw Object.assign(new Error('bad json'), { status: 400 }); }
137
+ }
138
+
139
+ // A send while a turn is under way is a tug (steering mid-run); the
140
+ // session itself stays open between turns, so "active" is not "busy".
141
+ function sendPrompt(text) {
142
+ const running = busy;
143
+ busy = true;
144
+ hub.tx({ kind: 'user', text });
145
+ if (running) hub.beat({ k: 'oversight', p: 'tug', agent: MAIN });
146
+ else hub.beat({ k: 'agent', p: 'start', id: MAIN, label: 'session' });
147
+ provider.send(text);
148
+ }
149
+
150
+ async function handle(req, res) {
151
+ const url = new URL(req.url, 'http://127.0.0.1');
152
+ if (!url.pathname.startsWith('/api/')) return serveStatic(req, res, url);
153
+ const bad = guard(req, url);
154
+ if (bad) return send(res, 403, { error: bad });
155
+ const route = `${req.method} ${url.pathname}`;
156
+ try {
157
+ switch (route) {
158
+ case 'GET /api/events': return hub.attach(req, res);
159
+ case 'GET /api/state': return send(res, 200, { info: await repoMod.repoInfo(repo), prefs, commands: repoMod.listCommands(repo), provider: provider.kind, permissionModes: PERMISSION_MODES });
160
+ case 'GET /api/preflight': return send(res, 200, await preflight(repo));
161
+ case 'GET /api/prompts': return send(res, 200, { paste: ADOPT_PASTE, adopt: readPrompt('adopt.md'), pitch: readPrompt('pitch.md') });
162
+ case 'POST /api/prefs': { const b = await body(req); if (b.permissionMode && !PERMISSION_MODES.includes(b.permissionMode)) delete b.permissionMode; prefs = await repoMod.writePrefs(repo, b); return send(res, 200, prefs); }
163
+ case 'POST /api/owner': { const b = await body(req); return send(res, 200, { owner: await repoMod.setOwner(repo, !!b.on) }); }
164
+ case 'POST /api/send': {
165
+ const b = await body(req);
166
+ const text = String(b.text || '').trim();
167
+ if (!text) return send(res, 400, { error: 'empty prompt' });
168
+ sendPrompt(text);
169
+ return send(res, 200, { ok: true });
170
+ }
171
+ case 'POST /api/adopt': await body(req); sendPrompt(ADOPT_PASTE); return send(res, 200, { ok: true });
172
+ case 'POST /api/interrupt': await body(req); await provider.interrupt(); return send(res, 200, { ok: true });
173
+ case 'POST /api/stop': await body(req); await provider.stop(); return send(res, 200, { ok: true });
174
+ case 'POST /api/fresh': {
175
+ await body(req);
176
+ await provider.fresh();
177
+ txCtx = newTxContext();
178
+ beatCtx = newBeatContext();
179
+ hub.reset();
180
+ return send(res, 200, { ok: true });
181
+ }
182
+ case 'GET /api/oversight': return send(res, 200, { live: [...asks.values()].map((a) => a.view), durable: repoMod.durableQuestions(repo) });
183
+ case 'POST /api/oversight/answer': {
184
+ const b = await body(req);
185
+ const rows = (Array.isArray(b.answers) ? b.answers : []).filter((a) => a && a.file && a.answer && String(a.answer).trim());
186
+ if (!rows.length) return send(res, 400, { error: 'no answers' });
187
+ sendPrompt(repoMod.answersPrompt(rows.map((a) => ({ file: String(a.file), line: Number(a.line) || 0, text: String(a.text || ''), answer: String(a.answer) })), repoMod.isOwner(repo)));
188
+ return send(res, 200, { ok: true });
189
+ }
190
+ case 'GET /api/claims': return send(res, 200, await repoMod.claims(repo));
191
+ case 'POST /api/claims/release': { const b = await body(req); return send(res, 200, await repoMod.releaseClaim(repo, String(b.id || ''))); }
192
+ case 'GET /api/progress': return send(res, 200, await repoMod.progress(repo, url.searchParams.get('spec')));
193
+ case 'GET /api/actions': return send(res, 200, await gh.actionsSetup(repo));
194
+ case 'POST /api/actions/workflow': { const b = await body(req); return send(res, 200, await gh.setWorkflow(repo, b.id, !!b.enabled)); }
195
+ case 'GET /api/runs': return send(res, 200, await gh.listRuns(repo));
196
+ }
197
+ const ask = /^POST \/api\/ask\/([\w-]+)$/.exec(route);
198
+ if (ask) {
199
+ const b = await body(req);
200
+ const entry = asks.get(ask[1]);
201
+ if (!entry) return send(res, 404, { error: 'no such question (already answered or expired)' });
202
+ asks.delete(ask[1]);
203
+ hub.beat({ k: 'oversight', p: 'answer', id: ask[1] });
204
+ publishAsks();
205
+ const answers = b.answers && typeof b.answers === 'object' ? Object.fromEntries(Object.entries(b.answers).map(([k, v]) => [String(k), String(v)])) : null;
206
+ entry.resolve(answers);
207
+ return send(res, 200, { ok: true });
208
+ }
209
+ const perm = /^POST \/api\/permission\/([\w-]+)$/.exec(route);
210
+ if (perm) {
211
+ const b = await body(req);
212
+ const entry = permissions.get(perm[1]);
213
+ if (!entry) return send(res, 404, { error: 'no such request' });
214
+ permissions.delete(perm[1]);
215
+ publishPerms();
216
+ entry.resolve({ allow: !!b.allow, always: !!b.always });
217
+ return send(res, 200, { ok: true });
218
+ }
219
+ return send(res, 404, { error: 'not found' });
220
+ } catch (e) {
221
+ return send(res, e.status || 500, { error: String(e.message || e) });
222
+ }
223
+ }
224
+
225
+ function serveStatic(req, res, url) {
226
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, { error: 'method' });
227
+ if (!hosts.has(req.headers.host || '')) return send(res, 403, { error: 'bad host' });
228
+ let rel;
229
+ try { rel = decodeURIComponent(url.pathname); } catch { return send(res, 400, { error: 'bad path' }); }
230
+ if (rel === '/' || !path.extname(rel)) rel = '/index.html';
231
+ const file = path.resolve(dist, `.${rel}`);
232
+ if (!file.startsWith(path.resolve(dist) + path.sep)) return send(res, 403, { error: 'path' });
233
+ if (!fs.existsSync(file)) {
234
+ if (rel === '/index.html') {
235
+ res.writeHead(200, { 'content-type': TYPES['.html'] });
236
+ return res.end('<!doctype html><meta charset="utf-8"><title>nexus</title><p>The UI is not built. Run <code>npm run build</code> in packages/cli.</p>');
237
+ }
238
+ return send(res, 404, { error: 'not found' });
239
+ }
240
+ res.writeHead(200, {
241
+ 'content-type': TYPES[path.extname(file)] || 'application/octet-stream',
242
+ 'cache-control': rel === '/index.html' ? 'no-store' : 'public, max-age=31536000, immutable',
243
+ 'x-content-type-options': 'nosniff',
244
+ 'referrer-policy': 'no-referrer',
245
+ ...(rel === '/index.html' ? { 'content-security-policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'" } : {}),
246
+ });
247
+ fs.createReadStream(file).pipe(res);
248
+ }
249
+
250
+ // Live Actions runs -> loop beats, only while a tab is watching.
251
+ const seenRuns = new Map();
252
+ let ghBroken = 0;
253
+ const poll = pollRuns ? setInterval(async () => {
254
+ if (!hub.clients || Date.now() < ghBroken) return;
255
+ const r = await gh.listRuns(repo);
256
+ if (!r.ok) { ghBroken = Date.now() + 60000; return; }
257
+ hub.set('runs', r.runs);
258
+ for (const b of gh.runBeats(r.runs, seenRuns)) hub.beat(b);
259
+ }, gh.POLL_MS) : null;
260
+
261
+ return {
262
+ url: `http://127.0.0.1:${actual}/?t=${encodeURIComponent(token)}`,
263
+ port: actual,
264
+ token,
265
+ hub,
266
+ async close() {
267
+ if (poll) clearInterval(poll);
268
+ await provider.stop?.();
269
+ hub.close();
270
+ await new Promise((r) => server.close(r));
271
+ },
272
+ };
273
+ }
274
+
275
+ function send(res, status, data) {
276
+ if (res.headersSent) return res.end();
277
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
278
+ res.end(JSON.stringify(data));
279
+ }
package/server/sh.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // One way to run a local program: no shell interpolation of user text,
2
+ // a timeout, and a result object instead of a throw.
3
+ import { spawn } from 'node:child_process';
4
+
5
+ const WIN = process.platform === 'win32';
6
+
7
+ // On Windows, npm-installed CLIs are .cmd shims that need a shell. Every
8
+ // argument passed through here is either a constant or validated by the
9
+ // caller (SAFE_ARG), so quoting is not a user-controlled surface.
10
+ export const SAFE_ARG = /^[\w@./:=,+{}-]*$/;
11
+
12
+ export function run(cmd, args = [], { cwd, timeout = 20000, input } = {}) {
13
+ return new Promise((resolve) => {
14
+ for (const a of args) {
15
+ if (WIN && !SAFE_ARG.test(a)) return resolve({ ok: false, code: -1, stdout: '', stderr: `refused unsafe argument: ${a}` });
16
+ }
17
+ let child;
18
+ try {
19
+ // Joined by hand on Windows: every argument passed SAFE_ARG above.
20
+ child = WIN
21
+ ? spawn([cmd, ...args].join(' '), { cwd, shell: true, windowsHide: true, env: process.env })
22
+ : spawn(cmd, args, { cwd, windowsHide: true, env: process.env });
23
+ } catch (e) {
24
+ return resolve({ ok: false, code: -1, stdout: '', stderr: String(e.message || e), missing: true });
25
+ }
26
+ let stdout = '';
27
+ let stderr = '';
28
+ const timer = setTimeout(() => { child.kill(); stderr += `\ntimed out after ${timeout} ms`; }, timeout);
29
+ child.stdout.on('data', (d) => { stdout += d; });
30
+ child.stderr.on('data', (d) => { stderr += d; });
31
+ child.on('error', (e) => {
32
+ clearTimeout(timer);
33
+ resolve({ ok: false, code: -1, stdout, stderr: String(e.message || e), missing: e.code === 'ENOENT' });
34
+ });
35
+ child.on('close', (code) => {
36
+ clearTimeout(timer);
37
+ const missing = code === 127 || (WIN && code === 1 && /is not recognized/.test(stderr));
38
+ resolve({ ok: code === 0, code, stdout, stderr, missing });
39
+ });
40
+ if (input != null) child.stdin.end(input);
41
+ else child.stdin.end();
42
+ });
43
+ }
44
+
45
+ export function json(text) {
46
+ try { return JSON.parse(text); } catch { return null; }
47
+ }
@@ -0,0 +1,190 @@
1
+ // Beats: the only things that move the agent stage.
2
+ //
3
+ // A beat is a small structured fact ("agent X started", "chunk 12.3
4
+ // reached the gate"). The stage never reads transcript words; it reduces
5
+ // beats. The reducer is pure (no clock, no DOM, no randomness), so a page
6
+ // opened mid-run, a refresh, and a page that watched from the start all
7
+ // reach the same state by replaying the same beats.
8
+ //
9
+ // Kinds and phases:
10
+ // chunk claim | build | gate | archive { id }
11
+ // agent start | tool | wait | end { id, parent?, label?, tool?, ok? }
12
+ // loop dispatch | run | conclude { id, name?, conclusion? }
13
+ // oversight ask | answer | tug { id?, agent?, text? }
14
+
15
+ export const LANES = 5;
16
+ export const MAIN = 'main';
17
+
18
+ export const KINDS = {
19
+ chunk: ['claim', 'build', 'gate', 'archive'],
20
+ agent: ['start', 'tool', 'wait', 'end'],
21
+ loop: ['dispatch', 'run', 'conclude'],
22
+ oversight: ['ask', 'answer', 'tug'],
23
+ };
24
+
25
+ export function isBeat(b) {
26
+ return !!b && typeof b === 'object' && Array.isArray(KINDS[b.k]) && KINDS[b.k].includes(b.p);
27
+ }
28
+
29
+ export function initialState() {
30
+ return {
31
+ seq: 0,
32
+ agents: {},
33
+ order: [],
34
+ chunks: {},
35
+ chunkOrder: [],
36
+ current: null,
37
+ loops: {},
38
+ loopOrder: [],
39
+ asks: {},
40
+ tugs: 0,
41
+ lastTug: null,
42
+ };
43
+ }
44
+
45
+ function ensureAgent(s, id, parent) {
46
+ if (s.agents[id]) return s.agents[id];
47
+ const children = s.order.filter((a) => a !== MAIN).length;
48
+ const a = {
49
+ id,
50
+ parent: id === MAIN ? null : parent || MAIN,
51
+ label: id === MAIN ? 'session' : '',
52
+ state: 'running',
53
+ tool: null,
54
+ tools: 0,
55
+ slot: id === MAIN ? 0 : (children % LANES) + 1,
56
+ ok: null,
57
+ };
58
+ s.agents[id] = a;
59
+ s.order.push(id);
60
+ return a;
61
+ }
62
+
63
+ /** Reduce one beat. Returns a new state; the input is not mutated. */
64
+ export function reduce(prev, beat) {
65
+ if (!isBeat(beat)) return prev;
66
+ const s = structuredCloneLite(prev);
67
+ s.seq += 1;
68
+ switch (beat.k) {
69
+ case 'agent': {
70
+ const id = beat.id || MAIN;
71
+ if (id !== MAIN && !s.agents[MAIN]) ensureAgent(s, MAIN);
72
+ const a = ensureAgent(s, id, beat.parent);
73
+ if (beat.label) a.label = String(beat.label).slice(0, 80);
74
+ if (beat.p === 'start') { a.state = 'running'; a.ok = null; }
75
+ if (beat.p === 'tool') { a.state = 'running'; a.tool = beat.tool || null; a.tools += 1; }
76
+ if (beat.p === 'wait') a.state = 'waiting';
77
+ if (beat.p === 'end') { a.state = beat.ok === false ? 'failed' : 'done'; a.ok = beat.ok !== false; a.tool = null; }
78
+ break;
79
+ }
80
+ case 'chunk': {
81
+ if (!beat.id) break;
82
+ if (!s.chunks[beat.id]) { s.chunks[beat.id] = { id: beat.id, stage: beat.p }; s.chunkOrder.push(beat.id); }
83
+ s.chunks[beat.id].stage = beat.p;
84
+ s.current = beat.p === 'archive' ? (s.current === beat.id ? null : s.current) : beat.id;
85
+ break;
86
+ }
87
+ case 'loop': {
88
+ if (!beat.id) break;
89
+ if (!s.loops[beat.id]) { s.loops[beat.id] = { id: beat.id, name: beat.name || '', status: beat.p, conclusion: null }; s.loopOrder.push(beat.id); }
90
+ const l = s.loops[beat.id];
91
+ l.status = beat.p;
92
+ if (beat.name) l.name = beat.name;
93
+ if (beat.p === 'conclude') l.conclusion = beat.conclusion || 'unknown';
94
+ break;
95
+ }
96
+ case 'oversight': {
97
+ if (beat.p === 'ask' && beat.id) {
98
+ s.asks[beat.id] = { id: beat.id, agent: beat.agent || MAIN, open: true };
99
+ const a = s.agents[beat.agent || MAIN];
100
+ if (a) a.state = 'waiting';
101
+ }
102
+ if (beat.p === 'answer' && beat.id && s.asks[beat.id]) {
103
+ s.asks[beat.id].open = false;
104
+ const a = s.agents[s.asks[beat.id].agent];
105
+ if (a && a.state === 'waiting') a.state = 'running';
106
+ }
107
+ if (beat.p === 'tug') { s.tugs += 1; s.lastTug = { n: s.tugs, agent: beat.agent || MAIN }; }
108
+ break;
109
+ }
110
+ }
111
+ return s;
112
+ }
113
+
114
+ export function replay(beats, from = initialState()) {
115
+ let s = from;
116
+ for (const b of beats) s = reduce(s, b);
117
+ return s;
118
+ }
119
+
120
+ export function openAsks(s) {
121
+ return Object.values(s.asks).filter((a) => a.open);
122
+ }
123
+
124
+ // JSON-shaped state only, so a JSON round trip is an exact clone.
125
+ function structuredCloneLite(s) {
126
+ return JSON.parse(JSON.stringify(s));
127
+ }
128
+
129
+ // --- beats from an agent message stream ------------------------------------
130
+ // Maps one SDK / stream-json message to zero or more beats. `ctx` carries the
131
+ // tool_use ids that spawned subagents, so their results end the right node.
132
+ // Structured fields only: tool names, tool inputs, parent_tool_use_id.
133
+
134
+ const SPAWN_TOOLS = new Set(['Task', 'Agent']);
135
+ const CLAIM_RE = /claims(?:\.mjs)?\s+claim\s+([0-9]+(?:\.[0-9a-z]+)?)/i;
136
+ const GATE_RE = /(verify\.mjs|check\.mjs|npm run (?:check|verify|test)|pnpm (?:verify|check))/i;
137
+ const PENDING_RE = /plan[\\/]pending[\\/]([0-9]+(?:\.[0-9a-z]+)?)\.md/i;
138
+
139
+ export function newBeatContext() {
140
+ return { spawns: {}, chunk: null };
141
+ }
142
+
143
+ export function beatsFromMessage(msg, ctx) {
144
+ const out = [];
145
+ if (!msg || typeof msg !== 'object') return out;
146
+ const parent = msg.parent_tool_use_id || null;
147
+ if (msg.type === 'system' && msg.subtype === 'init') out.push({ k: 'agent', p: 'start', id: MAIN, label: 'session' });
148
+ if (msg.type === 'system' && msg.subtype === 'task_started' && msg.tool_use_id && !ctx.spawns[msg.tool_use_id]) {
149
+ ctx.spawns[msg.tool_use_id] = true;
150
+ out.push({ k: 'agent', p: 'start', id: msg.tool_use_id, parent: MAIN, label: msg.description || msg.subagent_type || 'task' });
151
+ }
152
+ if (msg.type === 'system' && msg.subtype === 'task_notification' && msg.tool_use_id) {
153
+ out.push({ k: 'agent', p: 'end', id: msg.tool_use_id, ok: msg.status === 'completed' });
154
+ }
155
+ if (msg.type === 'assistant' && msg.message && Array.isArray(msg.message.content)) {
156
+ for (const block of msg.message.content) {
157
+ if (block.type !== 'tool_use') continue;
158
+ const input = block.input || {};
159
+ if (SPAWN_TOOLS.has(block.name)) {
160
+ if (!ctx.spawns[block.id]) {
161
+ ctx.spawns[block.id] = true;
162
+ out.push({ k: 'agent', p: 'start', id: block.id, parent: parent || MAIN, label: input.description || input.subagent_type || 'subagent' });
163
+ if (ctx.chunk) out.push({ k: 'chunk', p: 'build', id: ctx.chunk });
164
+ }
165
+ continue;
166
+ }
167
+ out.push({ k: 'agent', p: 'tool', id: parent || MAIN, tool: block.name });
168
+ const cmd = typeof input.command === 'string' ? input.command : '';
169
+ const file = typeof input.file_path === 'string' ? input.file_path : '';
170
+ const claim = CLAIM_RE.exec(cmd);
171
+ if (claim) { ctx.chunk = claim[1]; out.push({ k: 'chunk', p: 'claim', id: claim[1] }); }
172
+ else if (ctx.chunk && GATE_RE.test(cmd)) out.push({ k: 'chunk', p: 'gate', id: ctx.chunk });
173
+ const pending = PENDING_RE.exec(file) || PENDING_RE.exec(cmd);
174
+ if (pending && /^(Write|Edit)$/.test(block.name)) {
175
+ out.push({ k: 'chunk', p: 'archive', id: pending[1] });
176
+ if (ctx.chunk === pending[1]) ctx.chunk = null;
177
+ }
178
+ }
179
+ }
180
+ if (msg.type === 'user' && msg.message && Array.isArray(msg.message.content)) {
181
+ for (const block of msg.message.content) {
182
+ if (block && block.type === 'tool_result' && ctx.spawns[block.tool_use_id] === true) {
183
+ ctx.spawns[block.tool_use_id] = 'ended';
184
+ out.push({ k: 'agent', p: 'end', id: block.tool_use_id, ok: !block.is_error });
185
+ }
186
+ }
187
+ }
188
+ if (msg.type === 'result') out.push({ k: 'agent', p: 'end', id: MAIN, ok: !msg.is_error });
189
+ return out;
190
+ }
@@ -0,0 +1,60 @@
1
+ // Composer routing: what free text becomes before it reaches the session.
2
+ //
3
+ // "/anything ..." runs as typed (a slash command)
4
+ // "fix this bug please" /bug <text> reproduce, fix if small, else plot asap
5
+ // "add dark mode export" /plot <text> research, spec, chunk, slot in line
6
+ // "why is the gate red?" asked inline a plain question, answered in place
7
+ //
8
+ // The UI shows the route before sending and lets the person override it.
9
+
10
+ export const ROUTES = ['ask', 'bug', 'plot', 'slash'];
11
+
12
+ /** GUI-only aliases: the name on the button, the command it runs. */
13
+ export const ALIASES = { '/explode': '/messy' };
14
+
15
+ const QUESTION_START = /^(what|why|how|where|when|who|which|is|are|was|were|can|could|does|do|did|should|would|will|has|have|explain|tell me)\b/i;
16
+ const BUG_WORDS = /\b(bug|bugs|broken|breaks|crash(?:es|ed|ing)?|error|errors|exception|fails?|failing|failed|regression|doesn'?t work|does not work|not working|wrong|fix|fixes|glitch)\b/i;
17
+
18
+ export function classify(text) {
19
+ const t = String(text || '').trim();
20
+ if (!t) return null;
21
+ if (t.startsWith('/')) return 'slash';
22
+ if (BUG_WORDS.test(t) && !/\?$/.test(t)) return 'bug';
23
+ if (/\?$/.test(t) || QUESTION_START.test(t)) return BUG_WORDS.test(t) && /\b(fix|please)\b/i.test(t) ? 'bug' : 'ask';
24
+ return 'plot';
25
+ }
26
+
27
+ /** The prompt actually sent for `text` under `route` (default: classify(text)). */
28
+ export function toPrompt(text, route = classify(text), flags = {}) {
29
+ const t = String(text || '').trim();
30
+ if (!t || !route) return '';
31
+ let out;
32
+ if (route === 'slash') {
33
+ const [head, ...rest] = t.split(/\s+/);
34
+ const cmd = ALIASES[head.toLowerCase()] || head;
35
+ out = [cmd, ...rest].join(' ');
36
+ } else if (route === 'bug') out = `/bug ${t}`;
37
+ else if (route === 'plot') out = `/plot ${t}`;
38
+ else out = t;
39
+ return withRunFlags(out, flags);
40
+ }
41
+
42
+ /**
43
+ * "Delegate to sub-agents" / "Use workflows": overrides for /next and
44
+ * /heads-down. Neither checked leaves the size default (S/M agent, L workflow).
45
+ */
46
+ export function withRunFlags(prompt, { agents = false, workflows = false } = {}) {
47
+ if (!/^\/(next|heads-down)\b/.test(prompt)) return prompt;
48
+ if (/--(agents|workflow)\b/.test(prompt)) return prompt;
49
+ if (workflows) return `${prompt} --workflow`;
50
+ if (agents) return `${prompt} --agents`;
51
+ return prompt;
52
+ }
53
+
54
+ /** Slash completions for a draft: commands whose name starts with the typed word. */
55
+ export function complete(draft, commands) {
56
+ const m = /^\/([\w:-]*)$/.exec(String(draft || ''));
57
+ if (!m) return [];
58
+ const want = m[1].toLowerCase();
59
+ return commands.filter((c) => c.name.toLowerCase().startsWith(want)).slice(0, 8);
60
+ }