codehere 0.3.0-rc.9 → 0.3.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.
@@ -1,289 +1,686 @@
1
1
  /**
2
- * Interactive REPL for codehere.
2
+ * Interactive REPL · raw-mode live command palette.
3
3
  *
4
- * Bare `codehere` (no args) drops here — the CLI surface, parallel to the
5
- * web UI at `codehere start`. Type a task in plain English; the Conductor
6
- * routes to the right specialist; events stream to the terminal; you get
7
- * the next prompt when the task completes.
4
+ * Faithful port of v0.2.2's session.ts (commit f4828c2 · "REPL fixes,
5
+ * Palette Enter completes selected command, stdin multi-char chunk
6
+ * processing"). Same banner with state badges, same `> ` prompt with
7
+ * brand color, same filter-as-you-type palette under the prompt, same
8
+ * escape-buffering keystroke handler, same Tab/Enter complete, same
9
+ * arrow-key palette navigation when palette is visible / history when not.
8
10
  *
9
- * Why this exists:
10
- * - The v0.2 REPL was removed in D7 along with the rest of the v0.2 CLI
11
- * surface. That trim went too far — it removed a CLI entry point users
12
- * relied on. rc.6 brings it back, this time backed by the v0.3
13
- * Conductor (rule-based routing) instead of v0.2's smart-route.
14
- * - codehere is "PM you talk to" — terminal users should be able to talk
15
- * to it in the terminal, not be forced to a web UI. Both surfaces, both
16
- * first-class.
11
+ * Backends adapted to v0.3 (chat/edit/review/planner removed in D7):
12
+ * - Task slash commands (ask, explain, fix, review, plan, test, refactor,
13
+ * orchestrate, run) → spawn via Conductor
14
+ * - Subcommand slash (trust, report, hook, status, health, serve, setup,
15
+ * config) → invoke `node dist/index.js <cmd>` inline
16
+ * - /undo → "queued for v0.5 (F3 memory layer)"
17
17
  *
18
- * What's here:
19
- * - Read a line → route via Conductor → spawn specialist → stream events
20
- * to stdout → await completion → next prompt
21
- * - `/agents` lists installed specialists and the routing rules
22
- * - `/status` shows recent audit activity
23
- * - `/quit` (or Ctrl+C / Ctrl+D) exits cleanly
18
+ * Citing f4828c2 for lineage. Don't redesign.
24
19
  */
25
- import readline from 'readline';
26
- import { colors, newline } from '../../ui.js';
20
+ import { existsSync, readdirSync, readFileSync } from 'fs';
21
+ import { join, basename } from 'path';
22
+ import { spawn as childSpawn } from 'child_process';
23
+ import { fileURLToPath } from 'url';
24
+ import { dirname } from 'path';
25
+ import { colors, success, error, warning, newline, createCodehereLogo } from '../../ui.js';
27
26
  import { getVersion } from '../../utils/version.js';
28
27
  import { SessionManager, availableAgents } from '../../infrastructure/orchestration/session-manager.js';
28
+ import { collectCodeFiles } from '../../infrastructure/security/file-collection.js';
29
+ import chalk from 'chalk';
30
+ const brandBlue = chalk.rgb(74, 144, 226);
31
+ const stdin = process.stdin;
32
+ const stdout = process.stdout;
29
33
  const SLASH_COMMANDS = [
30
- // Meta · don't spawn agents · run locally
31
- { name: 'agents', aliases: ['a'], description: 'list installed specialists + routing rules', action: 'meta' },
32
- { name: 'status', aliases: ['s'], description: 'last 24h activity from the audit log', action: 'meta' },
33
- { name: 'history', aliases: ['hist'], description: 'recent prompts in this session', action: 'meta' },
34
- { name: 'web', description: 'launch the web UI at :3773 (in another terminal)', action: 'meta' },
35
- { name: 'clear', description: 'clear the screen', action: 'meta' },
36
- { name: 'help', aliases: ['?'], description: 'show this help', action: 'meta' },
37
- { name: 'quit', aliases: ['exit', 'q'], description: 'exit (Ctrl+C / Ctrl+D also work)', action: 'meta' },
38
- // Direct-route · skip the Conductor and send the prompt straight to a specific specialist
39
- { name: 'codex', args: '<prompt>', description: 'send to Codex specifically (skip Conductor)', action: 'route', targetAgent: 'codex' },
40
- { name: 'claude', args: '<prompt>', aliases: ['cc'], description: 'send to Claude Code (skip Conductor)', action: 'route', targetAgent: 'claude-code' },
41
- { name: 'aider', args: '<prompt>', description: 'send to Aider (skip Conductor)', action: 'route', targetAgent: 'aider' },
42
- { name: 'opencode', args: '<prompt>', aliases: ['oc'], description: 'send to OpenCode (skip Conductor)', action: 'route', targetAgent: 'opencode' },
34
+ // Tasks · delegate via Conductor
35
+ {
36
+ name: 'ask',
37
+ args: '<question>',
38
+ description: 'Ask anything about your codebase',
39
+ kind: 'task',
40
+ promptTemplate: (a) => a,
41
+ },
42
+ {
43
+ name: 'explain',
44
+ args: '<file>',
45
+ description: 'Explain a file',
46
+ kind: 'task',
47
+ promptTemplate: (a) => `Explain this file in plain English: ${a}`,
48
+ },
49
+ {
50
+ name: 'fix',
51
+ args: '<file> <hint>',
52
+ description: 'Apply a targeted fix',
53
+ kind: 'task',
54
+ promptTemplate: (a) => {
55
+ const parts = a.split(/\s+/);
56
+ const file = parts[0];
57
+ const hint = parts.slice(1).join(' ');
58
+ return `fix ${file}: ${hint}`;
59
+ },
60
+ },
61
+ {
62
+ name: 'review',
63
+ args: '<file>',
64
+ description: 'Code review with findings',
65
+ kind: 'task',
66
+ promptTemplate: (a) => `Review ${a} for bugs, security issues, and risks. Concrete findings only.`,
67
+ },
68
+ {
69
+ name: 'plan',
70
+ args: '<goal>',
71
+ description: 'Break a goal into steps',
72
+ kind: 'task',
73
+ promptTemplate: (a) => `Plan: ${a}. Break into concrete steps before any execution.`,
74
+ },
75
+ {
76
+ name: 'test',
77
+ args: '<file>',
78
+ description: 'Generate tests for a file',
79
+ kind: 'task',
80
+ promptTemplate: (a) => `add tests for ${a}. Cover edge cases and likely failure modes.`,
81
+ },
82
+ {
83
+ name: 'refactor',
84
+ args: '<pattern> <instr>',
85
+ description: 'Refactor across files',
86
+ kind: 'task',
87
+ promptTemplate: (a) => `refactor: ${a}`,
88
+ },
89
+ // Subcommands · run an existing CLI subcommand inline
90
+ {
91
+ name: 'trust',
92
+ args: '[path]',
93
+ description: 'Scan for vulnerabilities',
94
+ kind: 'sub',
95
+ noKey: true,
96
+ subcommand: 'trust',
97
+ },
98
+ {
99
+ name: 'report',
100
+ args: '[path]',
101
+ description: 'Security audit report',
102
+ kind: 'sub',
103
+ noKey: true,
104
+ subcommand: 'report',
105
+ },
106
+ {
107
+ name: 'hook',
108
+ args: '[install|status]',
109
+ description: 'Safety hook management',
110
+ kind: 'sub',
111
+ noKey: true,
112
+ subcommand: 'hook',
113
+ },
114
+ {
115
+ name: 'status',
116
+ args: '',
117
+ description: 'Dashboard — hook activity',
118
+ kind: 'sub',
119
+ noKey: true,
120
+ subcommand: 'status',
121
+ },
122
+ { name: 'health', args: '', description: 'System health check', kind: 'sub', noKey: true, subcommand: 'health' },
123
+ {
124
+ name: 'setup',
125
+ args: '',
126
+ description: 'Configure first-run preferences',
127
+ kind: 'sub',
128
+ noKey: true,
129
+ subcommand: 'setup',
130
+ },
131
+ {
132
+ name: 'config',
133
+ args: '[key] [value]',
134
+ description: 'Manage preferences',
135
+ kind: 'sub',
136
+ noKey: true,
137
+ subcommand: 'config',
138
+ },
139
+ { name: 'agents', args: '', description: 'List installed specialists', kind: 'session', noKey: true },
140
+ { name: 'undo', args: '<file>', description: 'Undo file changes (queued for v0.5)', kind: 'session', noKey: true },
141
+ { name: 'history', args: '', description: 'Show command history', kind: 'session', noKey: true },
142
+ // Helpers
143
+ { name: 'help', args: '', description: 'Command reference', kind: 'session', noKey: true },
144
+ { name: 'clear', args: '', description: 'Clear the screen', kind: 'session', noKey: true },
145
+ { name: 'exit', args: '', description: 'Leave the session', kind: 'session', noKey: true },
43
146
  ];
44
- function findSlashCommand(input) {
45
- const parts = input.trim().split(/\s+/);
46
- const head = parts[0].toLowerCase();
47
- for (const cmd of SLASH_COMMANDS) {
48
- if (cmd.name === head || (cmd.aliases ?? []).includes(head)) {
49
- return { command: cmd, args: parts.slice(1).join(' ').trim() };
147
+ const PALETTE_CMDS = SLASH_COMMANDS.map((c) => ({
148
+ sig: `/${c.name}${c.args ? ' ' + c.args : ''}`,
149
+ name: c.name,
150
+ desc: c.description,
151
+ noKey: c.noKey,
152
+ }));
153
+ function maxPalette() {
154
+ const rows = process.stdout.rows || 24;
155
+ return Math.min(rows - 8, 20);
156
+ }
157
+ // ── State detection (badges in welcome) ──────────────────────────────────
158
+ function detectState() {
159
+ const cwd = process.cwd();
160
+ const name = basename(cwd);
161
+ const settingsPath = join(process.env.HOME || '', '.claude', 'settings.json');
162
+ let hasHooks = false;
163
+ try {
164
+ hasHooks = existsSync(settingsPath) && readFileSync(settingsPath, 'utf-8').includes('codehere');
165
+ }
166
+ catch {
167
+ /* ignore */
168
+ }
169
+ const hasAnyAgent = availableAgents().some((a) => a.installed && a.id !== 'conductor');
170
+ const isGitRepo = existsSync(join(cwd, '.git'));
171
+ let fileCount = 0;
172
+ let scannableCount = 0;
173
+ try {
174
+ const count = (dir, d) => {
175
+ if (d > 3)
176
+ return 0;
177
+ let n = 0;
178
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
179
+ if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist')
180
+ continue;
181
+ if (e.isDirectory())
182
+ n += count(join(dir, e.name), d + 1);
183
+ else
184
+ n++;
185
+ }
186
+ return n;
187
+ };
188
+ fileCount = count(cwd, 0);
189
+ // rc.21: surface the scannable count — code files that trust/scan
190
+ // would actually look at. Honest count beats a single inflated number.
191
+ // UX_OBSERVATIONS P3. Static ESM import (rc.20 used `require()` which is
192
+ // undefined in this package's ESM runtime — Codex caught the silent fail).
193
+ try {
194
+ scannableCount = collectCodeFiles(cwd).length;
50
195
  }
196
+ catch {
197
+ /* ignore — banner stays silent rather than crashes */
198
+ }
199
+ }
200
+ catch {
201
+ /* ignore */
51
202
  }
52
- return null;
203
+ return { name, hasHooks, hasAgents: hasAnyAgent, isGitRepo, fileCount, scannableCount };
53
204
  }
54
- export async function runInteractiveRepl(options = {}) {
55
- const cwd = options.cwd ?? process.cwd();
56
- const history = [];
57
- newline();
58
- console.log(colors.bold('codehere ') + colors.dim('v' + getVersion()));
59
- console.log(colors.dim('Talk to your AI coding agents. Type a task to delegate; or use a /command.'));
60
- newline();
61
- console.log(colors.dim(' /help show all slash commands'));
62
- console.log(colors.dim(' /agents installed specialists + routing rules'));
63
- console.log(colors.dim(' /codex <…> skip the Conductor — send straight to a specific specialist'));
64
- console.log(colors.dim(' ↑/↓ history · Ctrl+L to clear · Ctrl+C / Ctrl+D to exit'));
65
- newline();
66
- const installed = availableAgents().filter((a) => a.installed);
67
- if (installed.length === 0) {
68
- console.log(colors.yellow(' No specialist agents installed. Install at least one to delegate tasks:'));
69
- console.log(colors.dim(' npm install -g @anthropic-ai/claude-code'));
70
- console.log(colors.dim(' npm install -g @openai/codex'));
71
- console.log(colors.dim(' brew install aider'));
72
- console.log(colors.dim(' curl -fsSL https://opencode.ai/install | bash'));
73
- newline();
74
- console.log(colors.dim('You can still use ') +
75
- colors.cyan('/status') +
76
- colors.dim(', ') +
77
- colors.cyan('codehere trust .') +
78
- colors.dim(', and the safety hook here.'));
79
- newline();
205
+ /**
206
+ * Format the banner's "X files (Y scannable)" line.
207
+ *
208
+ * Pure function (no I/O) so the rc.20 silent-fail regression that Codex
209
+ * caught — banner falling back to total-only when scannable load fails —
210
+ * has a tight unit test.
211
+ *
212
+ * Contract:
213
+ * - scannable > 0 AND scannable < total → "<total> files (<scannable> scannable)"
214
+ * - otherwise → "<total> files"
215
+ *
216
+ * Exported for test/cli/rc15-fixes.test.ts banner integration test.
217
+ */
218
+ export function formatFileLine(total, scannable) {
219
+ if (scannable > 0 && scannable < total) {
220
+ return `${total} files (${scannable} scannable)`;
80
221
  }
81
- const rl = readline.createInterface({
82
- input: process.stdin,
83
- output: process.stdout,
84
- prompt: colors.cyan('› '),
85
- terminal: true,
86
- historySize: 200,
87
- });
88
- const manager = SessionManager.getInstance();
89
- const promptNext = () => {
90
- rl.setPrompt(colors.cyan('› '));
91
- rl.prompt();
92
- };
93
- rl.on('close', () => {
94
- newline();
95
- console.log(colors.dim('Goodbye.'));
96
- process.exit(0);
97
- });
98
- rl.on('SIGINT', () => {
99
- rl.close();
100
- });
101
- promptNext();
102
- for await (const line of rl) {
103
- const trimmed = line.trim();
104
- if (!trimmed) {
105
- promptNext();
106
- continue;
222
+ return `${total} files`;
223
+ }
224
+ export function buildWelcomeLines(state, version) {
225
+ const logo = createCodehereLogo();
226
+ const ver = colors.dim(`v${version}`);
227
+ const badges = [
228
+ state.hasHooks ? colors.green('● hooks') : colors.dim('○ hooks'),
229
+ state.hasAgents ? colors.green('● agents') : colors.dim('○ agents'),
230
+ ];
231
+ const lines = [
232
+ '',
233
+ ` ${logo} ${ver}`,
234
+ '',
235
+ ' ' + badges.join(' '),
236
+ '',
237
+ colors.dim(` ${state.name}`) +
238
+ colors.dim(` · ${state.isGitRepo ? 'git' : 'dir'} · ${formatFileLine(state.fileCount, state.scannableCount)}`),
239
+ '',
240
+ ];
241
+ if (!state.hasHooks) {
242
+ lines.push(' ' + colors.yellow('→') + ' ' + colors.cyan('/hook install') + colors.dim(' to activate safety scanning'));
243
+ }
244
+ else if (!state.hasAgents) {
245
+ lines.push(' ' +
246
+ colors.yellow('→') +
247
+ ' Install ' +
248
+ colors.cyan('claude-code') +
249
+ colors.dim(' / ') +
250
+ colors.cyan('codex') +
251
+ colors.dim(' / aider / opencode to delegate tasks'));
252
+ }
253
+ lines.push(colors.dim(' Type / for commands, or ask anything'));
254
+ lines.push('');
255
+ return lines;
256
+ }
257
+ function printWelcome() {
258
+ const state = detectState();
259
+ for (const line of buildWelcomeLines(state, getVersion())) {
260
+ console.log(line);
261
+ }
262
+ }
263
+ // ── Main session ─────────────────────────────────────────────────────────
264
+ export async function runInteractiveRepl() {
265
+ printWelcome();
266
+ if (stdin.isTTY)
267
+ stdin.setRawMode(true);
268
+ stdin.resume();
269
+ stdin.setEncoding('utf8');
270
+ const state = { history: [] };
271
+ const historyBuf = [];
272
+ let historyIdx = -1;
273
+ let line = '';
274
+ let selected = 0;
275
+ let pLines = 0;
276
+ let processing = false;
277
+ let escBuf = '';
278
+ let escTimer = null;
279
+ const cols = () => stdout.columns || 80;
280
+ function truncate(s, maxWidth) {
281
+ // eslint-disable-next-line no-control-regex -- ANSI escape stripping requires the literal ESC byte
282
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
283
+ if (visible.length <= maxWidth)
284
+ return s;
285
+ let vis = 0;
286
+ let cut = 0;
287
+ for (let i = 0; i < s.length; i++) {
288
+ if (s[i] === '\x1b') {
289
+ const end = s.indexOf('m', i);
290
+ if (end !== -1) {
291
+ i = end;
292
+ continue;
293
+ }
294
+ }
295
+ vis++;
296
+ if (vis >= maxWidth - 1) {
297
+ cut = i + 1;
298
+ break;
299
+ }
107
300
  }
108
- history.push(trimmed);
109
- // Slash commands first
110
- if (trimmed.startsWith('/')) {
111
- const found = findSlashCommand(trimmed.slice(1));
112
- if (!found) {
113
- console.log(colors.yellow(` Unknown command: ${trimmed.split(/\s+/)[0]}`));
114
- console.log(colors.dim(' Run /help to see all commands.'));
115
- promptNext();
116
- continue;
301
+ return cut > 0 ? s.slice(0, cut) + '\x1b[0m' : s;
302
+ }
303
+ function render() {
304
+ stdout.write('\r\x1b[J');
305
+ stdout.write(brandBlue('> ') + line);
306
+ if (line.startsWith('/') && !line.includes(' ')) {
307
+ const filter = line.slice(1).toLowerCase();
308
+ const allMatching = PALETTE_CMDS.filter((c) => c.name.startsWith(filter));
309
+ const limit = maxPalette();
310
+ const filtered = allMatching.slice(0, limit);
311
+ const overflow = allMatching.length - filtered.length;
312
+ if (filtered.length > 0) {
313
+ const maxLen = Math.max(...filtered.map((c) => c.sig.length));
314
+ const w = cols();
315
+ let drawnLines = 0;
316
+ for (let i = 0; i < filtered.length; i++) {
317
+ const c = filtered[i];
318
+ const pad = ' '.repeat(Math.max(1, maxLen - c.sig.length + 2));
319
+ const badge = c.noKey ? colors.dim(' (free)') : '';
320
+ const prefix = i === selected ? brandBlue('▸') : ' ';
321
+ const text = i === selected ? brandBlue(c.sig) : c.sig;
322
+ const row = ` ${prefix} ${text}${pad}${colors.dim(c.desc)}${badge}`;
323
+ stdout.write('\n' + truncate(row, w));
324
+ drawnLines++;
325
+ }
326
+ if (overflow > 0) {
327
+ stdout.write('\n' + colors.dim(` ... +${overflow} more (type to filter)`));
328
+ drawnLines++;
329
+ }
330
+ stdout.write(`\x1b[${drawnLines}A`);
331
+ stdout.write(`\x1b[${line.length + 3}G`);
332
+ pLines = drawnLines;
333
+ }
334
+ else {
335
+ stdout.write('\n' + colors.dim(' No matching commands'));
336
+ stdout.write('\x1b[1A');
337
+ stdout.write(`\x1b[${line.length + 3}G`);
338
+ pLines = 1;
339
+ }
340
+ }
341
+ else {
342
+ pLines = 0;
343
+ }
344
+ }
345
+ function handle(key) {
346
+ if (processing)
347
+ return;
348
+ // Ctrl+C
349
+ if (key === '\x03') {
350
+ if (line.length > 0) {
351
+ line = '';
352
+ selected = 0;
353
+ stdout.write('\r\x1b[J\n');
354
+ render();
355
+ }
356
+ else {
357
+ stdout.write('\r\x1b[J\n');
358
+ process.exit(0);
359
+ }
360
+ return;
361
+ }
362
+ // Ctrl+D
363
+ if (key === '\x04') {
364
+ stdout.write('\r\x1b[J\n' + colors.dim(' See you.') + '\n');
365
+ process.exit(0);
366
+ }
367
+ // Ctrl+L
368
+ if (key === '\x0c') {
369
+ line = '';
370
+ selected = 0;
371
+ stdout.write('\x1b[2J\x1b[H');
372
+ printWelcome();
373
+ render();
374
+ return;
375
+ }
376
+ // Arrow up
377
+ if (key === '\x1b[A') {
378
+ if (pLines > 0) {
379
+ selected = Math.max(0, selected - 1);
380
+ }
381
+ else if (historyBuf.length > 0 && historyIdx < historyBuf.length - 1) {
382
+ historyIdx++;
383
+ line = historyBuf[historyIdx];
117
384
  }
118
- const { command, args } = found;
119
- switch (command.name) {
120
- case 'quit':
121
- rl.close();
122
- return;
123
- case 'help':
124
- printSlashCommandHelp();
125
- break;
126
- case 'clear':
127
- console.clear();
128
- newline();
129
- console.log(colors.bold('codehere ') + colors.dim('v' + getVersion()));
130
- newline();
131
- break;
132
- case 'agents':
133
- printAgentsAndRoutingRules();
134
- break;
135
- case 'status':
136
- await printStatusSummary();
137
- break;
138
- case 'history':
139
- printHistory(history);
140
- break;
141
- case 'web':
142
- console.log(colors.dim(' Tip: run ') +
143
- colors.cyan('codehere start') +
144
- colors.dim(' in another terminal to launch the web UI at http://127.0.0.1:3773'));
145
- break;
146
- default: {
147
- // Direct-route slash command (/codex, /claude, /aider, /opencode)
148
- if (command.action !== 'route' || !command.targetAgent)
149
- break;
150
- if (!args) {
151
- console.log(colors.yellow(` Usage: /${command.name} ${command.args ?? '<prompt>'}`));
152
- console.log(colors.dim(` Example: /${command.name} review src/auth.ts`));
153
- break;
154
- }
155
- if (!installed.some((a) => a.id === command.targetAgent)) {
156
- console.log(colors.yellow(` ${command.targetAgent} is not installed. Install it or use a different /command.`));
157
- break;
158
- }
159
- try {
160
- await runTaskWithAgent(args, cwd, manager, command.targetAgent);
161
- }
162
- catch (err) {
163
- console.log(colors.red(' ✗ ') + (err instanceof Error ? err.message : String(err)));
164
- }
165
- newline();
385
+ render();
386
+ return;
387
+ }
388
+ // Arrow down
389
+ if (key === '\x1b[B') {
390
+ if (pLines > 0) {
391
+ selected = Math.min(pLines - 1, selected + 1);
392
+ }
393
+ else {
394
+ if (historyIdx > 0) {
395
+ historyIdx--;
396
+ line = historyBuf[historyIdx];
397
+ }
398
+ else {
399
+ historyIdx = -1;
400
+ line = '';
166
401
  }
167
402
  }
168
- promptNext();
169
- continue;
403
+ render();
404
+ return;
170
405
  }
171
- // Plain text → delegate via Conductor
172
- if (installed.length === 0) {
173
- console.log(colors.yellow(' No specialist installed — install one and try again.'));
174
- promptNext();
175
- continue;
406
+ // Tab
407
+ if (key === '\t' && pLines > 0) {
408
+ const filter = line.slice(1).toLowerCase();
409
+ const filtered = PALETTE_CMDS.filter((c) => c.name.startsWith(filter)).slice(0, maxPalette());
410
+ if (filtered[selected]) {
411
+ line = `/${filtered[selected].name} `;
412
+ selected = 0;
413
+ }
414
+ render();
415
+ return;
176
416
  }
177
- try {
178
- await runTask(trimmed, cwd, manager);
417
+ // Enter
418
+ if (key === '\r' || key === '\n') {
419
+ if (pLines > 0 && line.startsWith('/') && !line.includes(' ')) {
420
+ const filter = line.slice(1).toLowerCase();
421
+ const filtered = PALETTE_CMDS.filter((c) => c.name.startsWith(filter)).slice(0, maxPalette());
422
+ if (filtered[selected]) {
423
+ line = `/${filtered[selected].name}`;
424
+ }
425
+ }
426
+ stdout.write('\r\x1b[J\n');
427
+ const trimmed = line.trim();
428
+ if (trimmed) {
429
+ historyBuf.unshift(trimmed);
430
+ historyIdx = -1;
431
+ }
432
+ line = '';
433
+ selected = 0;
434
+ pLines = 0;
435
+ if (!trimmed) {
436
+ render();
437
+ return;
438
+ }
439
+ processing = true;
440
+ executeInput(trimmed, state)
441
+ .catch((err) => {
442
+ console.log(error(' ' + (err instanceof Error ? err.message : String(err))));
443
+ })
444
+ .finally(() => {
445
+ processing = false;
446
+ stdin.resume();
447
+ stdin.ref();
448
+ if (stdin.isTTY && !stdin.isRaw)
449
+ stdin.setRawMode(true);
450
+ console.log();
451
+ render();
452
+ });
453
+ return;
179
454
  }
180
- catch (err) {
181
- console.log(colors.red(' ✗ ') + (err instanceof Error ? err.message : String(err)));
455
+ // Backspace
456
+ if (key === '\x7f' || key === '\b') {
457
+ if (line.length > 0) {
458
+ line = line.slice(0, -1);
459
+ selected = 0;
460
+ render();
461
+ }
462
+ return;
182
463
  }
183
- newline();
184
- promptNext();
464
+ // Ignore stray escape sequences
465
+ if (key.startsWith('\x1b'))
466
+ return;
467
+ // Regular character
468
+ line += key;
469
+ selected = 0;
470
+ render();
185
471
  }
186
- }
187
- /**
188
- * Run a task against a specific specialist (skipping the Conductor).
189
- * Used by /codex, /claude, /aider, /opencode slash commands.
190
- */
191
- async function runTaskWithAgent(prompt, cwd, manager, agentId) {
192
- const session = manager.spawn(prompt, agentId, { cwd });
193
- const started = colors.dim(' ┌─ ') +
194
- colors.bold(session.sessionId.slice(0, 8)) +
195
- colors.dim(' · ' + agentId + ' · ' + new Date(session.startedAt).toLocaleTimeString());
196
- console.log(started);
197
- await new Promise((resolve) => {
198
- const unsubscribe = session.subscribe((event) => {
199
- renderEvent(event);
200
- if (event.type === 'codehere:complete' || event.type === 'codehere:error' || event.type === 'exit') {
201
- unsubscribe();
202
- resolve();
472
+ render();
473
+ stdin.on('data', (raw) => {
474
+ if (processing)
475
+ return;
476
+ if (raw.startsWith('\x1b') && raw.length >= 2) {
477
+ handle(raw);
478
+ return;
479
+ }
480
+ for (let i = 0; i < raw.length; i++) {
481
+ if (processing)
482
+ return;
483
+ const ch = raw[i];
484
+ if (escBuf.length > 0) {
485
+ escBuf += ch;
486
+ if (escTimer)
487
+ clearTimeout(escTimer);
488
+ if (escBuf.length >= 3) {
489
+ const seq = escBuf;
490
+ escBuf = '';
491
+ handle(seq);
492
+ }
493
+ else {
494
+ escTimer = setTimeout(() => {
495
+ const seq = escBuf;
496
+ escBuf = '';
497
+ handle(seq);
498
+ }, 50);
499
+ }
500
+ continue;
203
501
  }
204
- });
502
+ if (ch === '\x1b') {
503
+ escBuf = ch;
504
+ escTimer = setTimeout(() => {
505
+ const seq = escBuf;
506
+ escBuf = '';
507
+ handle(seq);
508
+ }, 50);
509
+ continue;
510
+ }
511
+ handle(ch);
512
+ }
205
513
  });
206
- const finalStatus = session.status;
207
- const verdict = finalStatus === 'completed'
208
- ? colors.green(' └─ done')
209
- : finalStatus === 'killed'
210
- ? colors.yellow(' └─ killed')
211
- : colors.red(' └─ ' + finalStatus);
212
- console.log(verdict);
213
514
  }
214
- function printSlashCommandHelp() {
215
- newline();
216
- console.log(colors.bold('Slash commands'));
217
- console.log(colors.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
218
- newline();
219
- console.log(colors.bold(' Meta'));
220
- for (const cmd of SLASH_COMMANDS.filter((c) => c.action === 'meta')) {
221
- const aliases = cmd.aliases?.length ? colors.dim(' (' + cmd.aliases.map((a) => '/' + a).join(', ') + ')') : '';
222
- const cmdLabel = '/' + cmd.name + (cmd.args ? ' ' + cmd.args : '');
223
- console.log(' ' + colors.cyan(cmdLabel.padEnd(28)) + colors.dim(cmd.description) + aliases);
515
+ /** One-shot · `codehere "review src/auth.ts"` runs that task and exits. */
516
+ export async function runOneShot(prompt) {
517
+ const installed = availableAgents().filter((a) => a.installed);
518
+ if (installed.length === 0) {
519
+ console.log(warning('No specialist installed. Run `codehere setup` and try again.'));
520
+ process.exit(1);
224
521
  }
225
- newline();
226
- console.log(colors.bold(' Direct route') + colors.dim(' — skip the Conductor, send straight to a specialist'));
227
- for (const cmd of SLASH_COMMANDS.filter((c) => c.action === 'route')) {
228
- const aliases = cmd.aliases?.length ? colors.dim(' (' + cmd.aliases.map((a) => '/' + a).join(', ') + ')') : '';
229
- const cmdLabel = '/' + cmd.name + (cmd.args ? ' ' + cmd.args : '');
230
- console.log(' ' + colors.cyan(cmdLabel.padEnd(28)) + colors.dim(cmd.description) + aliases);
522
+ try {
523
+ await spawnViaConductor(prompt);
524
+ }
525
+ catch (err) {
526
+ console.error(error('Error:'), err instanceof Error ? err.message : String(err));
527
+ process.exit(1);
231
528
  }
232
- newline();
233
- console.log(colors.bold(' Or just type'));
234
- console.log(colors.dim(' Plain text in any other shape → Conductor routes to the right specialist'));
235
- console.log(colors.dim(' Examples: "review src/auth.ts" → claude-code · "fix the race in payments" → aider'));
236
- newline();
237
529
  }
238
- function printHistory(history) {
239
- newline();
240
- if (history.length === 0) {
241
- console.log(colors.dim(' No prompts yet in this session.'));
530
+ // ── Dispatch ─────────────────────────────────────────────────────────────
531
+ async function executeInput(input, state) {
532
+ const lower = input.toLowerCase();
533
+ if (lower.startsWith('/')) {
534
+ const parts = input.substring(1).trim().split(/\s+/);
535
+ const cmd = parts[0].toLowerCase();
536
+ const args = parts.slice(1).join(' ');
537
+ await handleSlash(cmd, args, state);
538
+ return;
539
+ }
540
+ // Legacy non-slash syntax (v0.2 backwards compat)
541
+ if (lower.startsWith('explain '))
542
+ return runTask(`Explain this file in plain English: ${input.substring(8).trim()}`, state);
543
+ if (lower.startsWith('e '))
544
+ return runTask(`Explain this file in plain English: ${input.substring(2).trim()}`, state);
545
+ if (lower.startsWith('fix ')) {
546
+ const rest = input.substring(4).trim();
547
+ const parts = rest.split(/\s+/);
548
+ return runTask(`fix ${parts[0]}: ${parts.slice(1).join(' ')}`, state);
549
+ }
550
+ if (lower.startsWith('review '))
551
+ return runTask(`Review ${input.substring(7).trim()} for bugs, security, and risks.`, state);
552
+ if (lower.startsWith('plan '))
553
+ return runTask(`Plan: ${input.substring(5).trim()}. Break into steps.`, state);
554
+ // Default: ask via Conductor
555
+ await runTask(input, state);
556
+ }
557
+ async function handleSlash(cmd, args, state) {
558
+ if (cmd === 'help' || cmd === '?') {
559
+ showSlashCommandMenu();
560
+ return;
561
+ }
562
+ if (cmd === 'clear') {
563
+ stdout.write('\x1b[2J\x1b[H');
564
+ printWelcome();
565
+ return;
566
+ }
567
+ if (cmd === 'exit' || cmd === 'quit' || cmd === 'q') {
568
+ console.log(colors.dim('Goodbye! 👋'));
569
+ process.exit(0);
570
+ }
571
+ if (cmd === 'agents') {
572
+ const all = availableAgents();
573
+ newline();
574
+ console.log(colors.bold(' Installed specialists:'));
575
+ for (const a of all) {
576
+ const tag = a.installed ? colors.green(' ✓ ') : colors.dim(' · ');
577
+ const status = a.installed ? '' : colors.dim(' (not installed)');
578
+ console.log(tag + colors.cyan(a.id.padEnd(14)) + colors.dim(a.label) + status);
579
+ }
242
580
  newline();
243
581
  return;
244
582
  }
245
- console.log(colors.bold(' Recent prompts:'));
246
- history.slice(-10).forEach((h, i) => {
247
- const preview = h.length > 70 ? h.slice(0, 67) + '…' : h;
248
- console.log(colors.dim(` ${(i + 1).toString().padStart(2, ' ')}. `) + preview);
249
- });
583
+ if (cmd === 'history' || cmd === 'h') {
584
+ if (state.history.length === 0) {
585
+ console.log(colors.dim('No command history yet.'));
586
+ }
587
+ else {
588
+ console.log(colors.bold('Recent Commands:'));
589
+ state.history.slice(-10).forEach((h, i) => {
590
+ const preview = h.command.length > 60 ? h.command.slice(0, 57) + '…' : h.command;
591
+ console.log(colors.dim(` ${i + 1}. ${preview}`));
592
+ });
593
+ }
594
+ return;
595
+ }
596
+ if (cmd === 'undo') {
597
+ console.log(colors.dim(' /undo is queued for v0.5 (F3 memory layer).'));
598
+ console.log(colors.dim(' For now: ') + colors.cyan('git diff') + colors.dim(' / ') + colors.cyan('git checkout -- <file>'));
599
+ return;
600
+ }
601
+ const def = SLASH_COMMANDS.find((c) => c.name === cmd);
602
+ if (!def) {
603
+ console.log(warning(`Unknown command: /${cmd}`));
604
+ console.log(colors.dim('Type /help to see available commands'));
605
+ return;
606
+ }
607
+ if (def.kind === 'task' && def.promptTemplate) {
608
+ if (!args && def.args) {
609
+ console.log(warning(`Usage: /${def.name} ${def.args}`));
610
+ return;
611
+ }
612
+ await runTask(def.promptTemplate(args), state);
613
+ return;
614
+ }
615
+ if (def.kind === 'sub' && def.subcommand) {
616
+ await runSubcommand(def.subcommand, args ? args.split(/\s+/) : []);
617
+ return;
618
+ }
619
+ }
620
+ function showSlashCommandMenu() {
621
+ newline();
622
+ console.log(colors.bold(' Slash commands'));
623
+ newline();
624
+ const maxNameLen = Math.max(...SLASH_COMMANDS.map((c) => c.name.length + c.args.length)) + 1;
625
+ for (const cmd of SLASH_COMMANDS) {
626
+ const signature = `/${cmd.name}${cmd.args ? ' ' + cmd.args : ''}`;
627
+ const pad = ' '.repeat(Math.max(0, maxNameLen + 2 - signature.length + 8));
628
+ const badge = cmd.noKey ? colors.dim(' (free)') : '';
629
+ console.log(` ${colors.cyan(signature)}${pad}${colors.dim(cmd.description)}${badge}`);
630
+ }
631
+ newline();
632
+ console.log(colors.dim(' Type any command with / prefix, or just ask a question directly.'));
250
633
  newline();
251
634
  }
252
- /**
253
- * Run a single task and exit — bypass the REPL prompt loop.
254
- * Use case: `codehere "review src/auth.ts"` or scripts piping a task in.
255
- */
256
- export async function runOneShot(prompt, cwd = process.cwd()) {
635
+ // ── Run-task / Run-subcommand helpers ────────────────────────────────────
636
+ async function runTask(prompt, state) {
637
+ await spawnViaConductor(prompt, state);
638
+ }
639
+ async function runSubcommand(name, extraArgs) {
640
+ const here = fileURLToPath(import.meta.url);
641
+ const cliPath = join(dirname(here), '..', '..', 'index.js');
642
+ await new Promise((resolve) => {
643
+ const child = childSpawn('node', [cliPath, name, ...extraArgs], { stdio: 'inherit' });
644
+ child.on('close', () => resolve());
645
+ child.on('error', (err) => {
646
+ console.log(error(' ' + err.message));
647
+ resolve();
648
+ });
649
+ });
650
+ }
651
+ async function spawnViaConductor(prompt, state) {
257
652
  const installed = availableAgents().filter((a) => a.installed);
258
653
  if (installed.length === 0) {
259
- console.log(colors.yellow(' No specialist installed. Install one (see `codehere setup`) and try again.'));
260
- process.exit(1);
654
+ console.log(warning('No specialist installed — install one and try again.'));
655
+ return;
261
656
  }
262
657
  const manager = SessionManager.getInstance();
263
- try {
264
- await runTask(prompt, cwd, manager);
265
- }
266
- catch (err) {
267
- console.log(colors.red(' ✗ ') + (err instanceof Error ? err.message : String(err)));
268
- process.exit(1);
269
- }
270
- }
271
- async function runTask(prompt, cwd, manager) {
272
- const session = manager.spawn(prompt, 'conductor', { cwd });
273
- const startedLabel = colors.dim(' ┌─ ') + colors.bold(session.sessionId.slice(0, 8)) + colors.dim(' · ' + new Date(session.startedAt).toLocaleTimeString());
274
- console.log(startedLabel);
658
+ const session = manager.spawn(prompt, 'conductor', { cwd: process.cwd() });
659
+ console.log(colors.dim(' ┌─ ') +
660
+ colors.bold(session.sessionId.slice(0, 8)) +
661
+ colors.dim(' · ' + new Date(session.startedAt).toLocaleTimeString()));
662
+ let captured = '';
275
663
  await new Promise((resolve) => {
276
664
  const unsubscribe = session.subscribe((event) => {
277
- renderEvent(event);
665
+ const text = renderEvent(event);
666
+ if (text)
667
+ captured += text + '\n';
278
668
  if (event.type === 'codehere:complete' || event.type === 'codehere:error' || event.type === 'exit') {
279
669
  unsubscribe();
280
670
  resolve();
281
671
  }
282
672
  });
283
673
  });
284
- const finalStatus = session.status;
285
- const verdict = finalStatus === 'completed' ? colors.green(' └─ done') : finalStatus === 'killed' ? colors.yellow(' └─ killed') : colors.red(' └─ ' + finalStatus);
674
+ const status = session.status;
675
+ const verdict = status === 'completed'
676
+ ? success(' └─ done')
677
+ : status === 'killed'
678
+ ? warning(' └─ killed')
679
+ : error(' └─ ' + status);
286
680
  console.log(verdict);
681
+ if (state) {
682
+ state.history.push({ command: prompt, response: captured, timestamp: new Date().toISOString() });
683
+ }
287
684
  }
288
685
  function renderEvent(event) {
289
686
  switch (event.type) {
@@ -293,11 +690,7 @@ function renderEvent(event) {
293
690
  colors.bold('routing → ') +
294
691
  colors.cyan(data.delegatedTo ?? '?') +
295
692
  colors.dim(' · ' + (data.reason ?? '')));
296
- break;
297
- }
298
- case 'codehere:steps': {
299
- // Step snapshots replay frequently — skip the noisy ones; the UI handles them
300
- break;
693
+ return null;
301
694
  }
302
695
  case 'stdout':
303
696
  case 'stderr': {
@@ -305,89 +698,21 @@ function renderEvent(event) {
305
698
  const line = data?.line;
306
699
  if (line) {
307
700
  const text = String(line).trimEnd();
308
- if (text)
701
+ if (text) {
309
702
  console.log(colors.dim(' │ ') + text);
703
+ return text;
704
+ }
310
705
  }
311
- break;
706
+ return null;
312
707
  }
313
708
  case 'error': {
314
709
  const data = event.data;
315
710
  if (data?.message)
316
711
  console.log(colors.red(' │ ✗ ') + data.message);
317
- break;
318
- }
319
- case 'codehere:complete':
320
- case 'exit': {
321
- // handled by runTask's verdict line
322
- break;
712
+ return null;
323
713
  }
324
714
  default:
325
- // Other event types — quiet by default to keep the REPL readable
326
- break;
327
- }
328
- }
329
- function printAgentsAndRoutingRules() {
330
- newline();
331
- console.log(colors.bold(' Installed specialists:'));
332
- const all = availableAgents();
333
- for (const a of all) {
334
- const tag = a.installed ? colors.green(' ✓ ') : colors.dim(' · ');
335
- const status = a.installed ? '' : colors.dim(' (not installed)');
336
- console.log(tag + colors.cyan(a.id.padEnd(14)) + colors.dim(a.label) + status);
337
- }
338
- newline();
339
- console.log(colors.bold(' Routing (rule-based; LLM classifier in v0.4):'));
340
- console.log(colors.dim(' review · audit · explain → claude-code'));
341
- console.log(colors.dim(' refactor · restructure → codex'));
342
- console.log(colors.dim(' fix · patch · bugfix → aider'));
343
- console.log(colors.dim(' scaffold · generate · bootstrap → opencode'));
344
- console.log(colors.dim(' add tests · write tests → codex'));
345
- console.log(colors.dim(' (anything else) → claude-code'));
346
- newline();
347
- }
348
- async function printStatusSummary() {
349
- newline();
350
- try {
351
- const { existsSync, readFileSync } = await import('fs');
352
- const { join } = await import('path');
353
- const { homedir } = await import('os');
354
- const auditPath = process.env.CODEHERE_AUDIT_FILE || join(homedir(), '.codehere', 'audit', 'hook-events.jsonl');
355
- if (!existsSync(auditPath)) {
356
- console.log(colors.dim(' No audit log yet. Run a task or `codehere hook install`.'));
357
- newline();
358
- return;
359
- }
360
- const lines = readFileSync(auditPath, 'utf-8').split('\n').filter(Boolean).slice(-10);
361
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
362
- let actions = 0;
363
- let blocked = 0;
364
- let trustSum = 0;
365
- let trustN = 0;
366
- for (const line of lines) {
367
- try {
368
- const entry = JSON.parse(line);
369
- const ts = entry.timestamp ? Date.parse(entry.timestamp) : 0;
370
- if (ts < cutoff)
371
- continue;
372
- actions++;
373
- if (entry.blocked)
374
- blocked++;
375
- if (typeof entry.riskScore === 'number') {
376
- trustSum += 100 - entry.riskScore;
377
- trustN++;
378
- }
379
- }
380
- catch {
381
- /* skip malformed line */
382
- }
383
- }
384
- const avgTrust = trustN > 0 ? Math.round(trustSum / trustN) : null;
385
- console.log(colors.bold(' Last 24h:'));
386
- console.log(` ${actions} actions · ${blocked} blocked${avgTrust !== null ? ` · avg trust ${avgTrust}` : ''}`);
715
+ return null;
387
716
  }
388
- catch (err) {
389
- console.log(colors.dim(' Could not read audit log: ' + (err instanceof Error ? err.message : String(err))));
390
- }
391
- newline();
392
717
  }
393
718
  //# sourceMappingURL=repl.js.map