codehere 0.3.0-rc.1 → 0.3.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +55 -52
- package/dist/infrastructure/hooks/claude-sdk-hook.js +17 -13
- package/dist/infrastructure/orchestration/conductor-adapter.js +340 -0
- package/dist/infrastructure/orchestration/session-manager.js +6 -0
- package/dist/infrastructure/protocols/probe-port.js +36 -0
- package/dist/presentation/cli/repl.js +474 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,7 +14,6 @@ import { Command } from 'commander';
|
|
|
14
14
|
import { config } from 'dotenv';
|
|
15
15
|
import { join, dirname } from 'path';
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
|
-
import { existsSync, readFileSync } from 'fs';
|
|
18
17
|
import { executeSetupCommand } from './presentation/cli/commands/setup-command.js';
|
|
19
18
|
import { executeConfigCommand } from './presentation/cli/commands/config-command.js';
|
|
20
19
|
import { executeHelpCommand } from './presentation/cli/commands/help-command.js';
|
|
@@ -44,54 +43,25 @@ program
|
|
|
44
43
|
.description('A PM for your AI coding agents. Delegate a task, watch it run, get a plain-English report with a trust score on every file.')
|
|
45
44
|
.version(getVersion())
|
|
46
45
|
.configureHelp({ helpWidth: 80, sortSubcommands: true });
|
|
47
|
-
// Default action · show welcome
|
|
46
|
+
// Default action · show welcome, then drop into the interactive REPL on TTY.
|
|
47
|
+
// codehere offers TWO surfaces, both first-class: the CLI REPL (here) and the
|
|
48
|
+
// web UI (`codehere start`). Users pick whichever fits their workflow.
|
|
48
49
|
program
|
|
49
|
-
.argument('[query]', '
|
|
50
|
+
.argument('[query]', 'Optional initial task to delegate (otherwise enter interactive REPL)')
|
|
50
51
|
.action(async (query) => {
|
|
52
|
+
// One-shot delegate: `codehere "review src/auth.ts"` runs that task and exits.
|
|
53
|
+
// Useful for scripting or quick one-offs without entering the REPL.
|
|
51
54
|
if (query && query.trim()) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
console.log(' ' + colors.cyan(`codehere task "${query.trim().slice(0, 40)}..."`) + ' → spawn via CLI');
|
|
56
|
-
process.exit(1);
|
|
55
|
+
const { runOneShot } = await import('./presentation/cli/repl.js');
|
|
56
|
+
await runOneShot(query.trim());
|
|
57
|
+
return;
|
|
57
58
|
}
|
|
58
59
|
if (!process.stdin.isTTY) {
|
|
59
60
|
console.log(createWelcomeMessage());
|
|
60
61
|
return;
|
|
61
62
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const hasHooks = existsSync(settingsPath) && readFileSync(settingsPath, 'utf-8').includes('codehere');
|
|
65
|
-
const hasAuditLog = existsSync(join(codehereDir, 'audit', 'hook-events.jsonl'));
|
|
66
|
-
console.log(createWelcomeMessage());
|
|
67
|
-
newline();
|
|
68
|
-
if (!hasHooks && !hasAuditLog) {
|
|
69
|
-
console.log(colors.bold("Welcome! Let's get you set up."));
|
|
70
|
-
newline();
|
|
71
|
-
console.log(' ' + colors.bold('Safety first — no API key, instant'));
|
|
72
|
-
console.log(colors.dim(' Every AI write gets scanned. Catches injections, hardcoded secrets,'));
|
|
73
|
-
console.log(colors.dim(' destructive ops, and more.'));
|
|
74
|
-
newline();
|
|
75
|
-
console.log(' → Run ' + colors.cyan('codehere hook install') + ' to activate safety');
|
|
76
|
-
console.log(' → Run ' + colors.cyan('codehere trust .') + ' to scan this project');
|
|
77
|
-
newline();
|
|
78
|
-
console.log(' ' + colors.bold('Delegate tasks — the PM for your AI coding agents'));
|
|
79
|
-
console.log(colors.dim(' Pick Claude Code, Codex, Aider, or OpenCode. codehere coordinates.'));
|
|
80
|
-
newline();
|
|
81
|
-
console.log(' → Run ' + colors.cyan('codehere start') + ' to open the task UI');
|
|
82
|
-
newline();
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
console.log(colors.bold('Quick start:'));
|
|
86
|
-
newline();
|
|
87
|
-
console.log(' ' + colors.cyan('codehere start') + ' Task intake UI at http://127.0.0.1:3773');
|
|
88
|
-
console.log(' ' + colors.cyan('codehere status') + ' Dashboard · sessions · blocked calls');
|
|
89
|
-
console.log(' ' + colors.cyan('codehere report') + ' Generate a shareable audit report');
|
|
90
|
-
console.log(' ' + colors.cyan('codehere trust .') + ' Scan this project now');
|
|
91
|
-
newline();
|
|
92
|
-
console.log(colors.dim(' Run `codehere --help` for all commands.'));
|
|
93
|
-
newline();
|
|
94
|
-
}
|
|
63
|
+
const { runInteractiveRepl } = await import('./presentation/cli/repl.js');
|
|
64
|
+
await runInteractiveRepl();
|
|
95
65
|
});
|
|
96
66
|
// ── setup ────────────────────────────────────────────────────────────────────
|
|
97
67
|
program
|
|
@@ -354,10 +324,30 @@ program
|
|
|
354
324
|
const port = parseInt(options?.port ?? '3773', 10);
|
|
355
325
|
const host = options?.host ?? '127.0.0.1';
|
|
356
326
|
const shouldOpen = options?.open !== false;
|
|
327
|
+
const url = `http://${host}:${port}/`;
|
|
328
|
+
const openBrowser = async () => {
|
|
329
|
+
if (!shouldOpen)
|
|
330
|
+
return;
|
|
331
|
+
const { spawn } = await import('child_process');
|
|
332
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
333
|
+
try {
|
|
334
|
+
spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
/* browser open is best-effort */
|
|
338
|
+
}
|
|
339
|
+
};
|
|
357
340
|
try {
|
|
358
341
|
const { startMCPServer } = await import('./infrastructure/protocols/mcp-server-impl.js');
|
|
359
342
|
const server = startMCPServer({ port, host });
|
|
360
|
-
|
|
343
|
+
// Wait for the server to either start listening or fail. The default
|
|
344
|
+
// Node behavior on listen() failure is an unhandled 'error' event which
|
|
345
|
+
// crashes the process with a stack trace. We catch it here so the user
|
|
346
|
+
// sees a friendly message instead.
|
|
347
|
+
await new Promise((resolve, reject) => {
|
|
348
|
+
server.once('listening', () => resolve());
|
|
349
|
+
server.once('error', reject);
|
|
350
|
+
});
|
|
361
351
|
newline();
|
|
362
352
|
console.log(success('codehere is running'));
|
|
363
353
|
newline();
|
|
@@ -367,16 +357,7 @@ program
|
|
|
367
357
|
newline();
|
|
368
358
|
console.log(colors.dim(' Press Ctrl+C to stop'));
|
|
369
359
|
newline();
|
|
370
|
-
|
|
371
|
-
const { spawn } = await import('child_process');
|
|
372
|
-
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
373
|
-
try {
|
|
374
|
-
spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
/* browser open is best-effort */
|
|
378
|
-
}
|
|
379
|
-
}
|
|
360
|
+
await openBrowser();
|
|
380
361
|
process.on('SIGINT', () => {
|
|
381
362
|
server.close();
|
|
382
363
|
console.log(colors.dim('\ncodehere stopped'));
|
|
@@ -387,6 +368,28 @@ program
|
|
|
387
368
|
});
|
|
388
369
|
}
|
|
389
370
|
catch (err) {
|
|
371
|
+
const code = err?.code;
|
|
372
|
+
if (code === 'EADDRINUSE') {
|
|
373
|
+
// Port is taken. Probe to find out by what — if it's a running codehere,
|
|
374
|
+
// we should hand the user the existing URL; if it's something else,
|
|
375
|
+
// tell them to pick a different port.
|
|
376
|
+
const { isCodehereOnPort } = await import('./infrastructure/protocols/probe-port.js');
|
|
377
|
+
const existing = await isCodehereOnPort(host, port);
|
|
378
|
+
newline();
|
|
379
|
+
if (existing.isCodehere) {
|
|
380
|
+
console.log(success(`codehere is already running at ${url}`));
|
|
381
|
+
if (existing.version) {
|
|
382
|
+
console.log(colors.dim(` version: ${existing.version}`));
|
|
383
|
+
}
|
|
384
|
+
newline();
|
|
385
|
+
console.log(colors.dim(' Opening browser...'));
|
|
386
|
+
await openBrowser();
|
|
387
|
+
process.exit(0);
|
|
388
|
+
}
|
|
389
|
+
console.error(error(`Port ${port} is in use by another process.`));
|
|
390
|
+
console.error(colors.dim(` Try: codehere start --port ${port + 1}`));
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
390
393
|
console.error(error('Failed to start:'), err instanceof Error ? err.message : String(err));
|
|
391
394
|
process.exit(1);
|
|
392
395
|
}
|
|
@@ -44,7 +44,7 @@ const PATTERNS = [
|
|
|
44
44
|
description: 'Role hijacking attempt',
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
|
-
regex:
|
|
47
|
+
regex: /\bsystem\s+prompt\b|<\/?system>|(?<!['"\\])<\/?instruction>/i,
|
|
48
48
|
severity: 'critical',
|
|
49
49
|
category: 'Prompt Injection',
|
|
50
50
|
description: 'System prompt injection',
|
|
@@ -56,7 +56,7 @@ const PATTERNS = [
|
|
|
56
56
|
description: 'Instruction override attempt',
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
|
-
regex:
|
|
59
|
+
regex: /\bjailbreak\b|\bDAN\s+mode\b|\bdeveloper\s+mode\s+enabled\b/i,
|
|
60
60
|
severity: 'critical',
|
|
61
61
|
category: 'Prompt Injection',
|
|
62
62
|
description: 'Jailbreak attempt',
|
|
@@ -93,7 +93,7 @@ const PATTERNS = [
|
|
|
93
93
|
description: 'Exfiltrates env secrets',
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
|
-
regex:
|
|
96
|
+
regex: /\bprintenv\b|\benv\s*\|\s*(grep|curl|wget|nc)\b/i,
|
|
97
97
|
severity: 'high',
|
|
98
98
|
category: 'Credential Theft',
|
|
99
99
|
description: 'Environment variable exfiltration',
|
|
@@ -119,7 +119,7 @@ const PATTERNS = [
|
|
|
119
119
|
description: 'SSH key tooling access',
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
|
-
regex:
|
|
122
|
+
regex: /\bkeychain\b|\bsecurity\s+find-generic-password\b/i,
|
|
123
123
|
severity: 'high',
|
|
124
124
|
category: 'Credential Theft',
|
|
125
125
|
description: 'macOS keychain access attempt',
|
|
@@ -417,10 +417,14 @@ export function appendAuditLog(entry) {
|
|
|
417
417
|
* not execution — strip the search term so pattern strings in args don't
|
|
418
418
|
* trigger the scanner.
|
|
419
419
|
*
|
|
420
|
-
* Write / Edit / MultiEdit: the content is
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
420
|
+
* Write / Edit / MultiEdit: the content is DATA being written to disk, not
|
|
421
|
+
* a command being executed. Only scan the file path (destination) — that's
|
|
422
|
+
* enough to catch writes to sensitive files (~/.ssh/id_rsa, ~/.aws/credentials).
|
|
423
|
+
* Do NOT scan content: it produces false positives on legitimate source code
|
|
424
|
+
* that mentions attack-shaped strings (i18n with "system prompt", README docs
|
|
425
|
+
* describing what we block, LLM-prompt-engineering code, security tests).
|
|
426
|
+
* The Bash surface is the right place to catch prompt-injection / code-injection
|
|
427
|
+
* patterns; Edit-time content is data, not execution intent.
|
|
424
428
|
*/
|
|
425
429
|
function buildScanPayload(toolName, toolInput) {
|
|
426
430
|
if (toolName === 'Bash') {
|
|
@@ -438,11 +442,11 @@ function buildScanPayload(toolName, toolInput) {
|
|
|
438
442
|
return JSON.stringify({ file_path: toolInput.file_path ?? '' });
|
|
439
443
|
}
|
|
440
444
|
if (toolName === 'Edit') {
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
});
|
|
445
|
+
// Same model as Write: scan only the destination. The old_string is
|
|
446
|
+
// EXISTING file content (data), not execution intent — scanning it
|
|
447
|
+
// produced false positives on legitimate edits to files containing
|
|
448
|
+
// attack-shaped strings (e.g. i18n.js with a "system prompt" key).
|
|
449
|
+
return JSON.stringify({ file_path: toolInput.file_path ?? '' });
|
|
446
450
|
}
|
|
447
451
|
if (toolName === 'MultiEdit') {
|
|
448
452
|
// Scan file paths only
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F6 Conductor — codehere's own meta-agent.
|
|
3
|
+
*
|
|
4
|
+
* Plain English in, agent actions out. The Conductor is a first-class
|
|
5
|
+
* AgentAdapter that doesn't fork a subprocess — instead it ROUTES the
|
|
6
|
+
* incoming prompt to one of the dogfooded specialists (Claude Code, Codex,
|
|
7
|
+
* Aider, OpenCode) based on intent, then proxies the spawned session's
|
|
8
|
+
* events back to the caller verbatim, with a single Conductor-attributed
|
|
9
|
+
* "delegated to X" event prepended so the audit log records the routing
|
|
10
|
+
* decision.
|
|
11
|
+
*
|
|
12
|
+
* This is the v0.4-alpha SCAFFOLD. Routing is rule-based (keyword → adapter)
|
|
13
|
+
* for now. The v0.4 stable target replaces the rule lookup with an LLM
|
|
14
|
+
* classifier — Claude Haiku 4.5 prompted to pick the best specialist given
|
|
15
|
+
* the user's request, the available agents, and any context from past
|
|
16
|
+
* sessions. The seam (`pickSpecialist`) is the same; only the body changes.
|
|
17
|
+
*
|
|
18
|
+
* Why this matters: until Conductor exists, codehere is "a UI over other
|
|
19
|
+
* people's agents." With Conductor, codehere becomes a participant in the
|
|
20
|
+
* thread — the user talks to *codehere*, codehere talks to the agents, and
|
|
21
|
+
* the user gets a single conversational surface instead of having to know
|
|
22
|
+
* which specialist suits the task. This is the seam the v0.4+ Slack-shape
|
|
23
|
+
* thread UX (`@conductor` mention) attaches to.
|
|
24
|
+
*
|
|
25
|
+
* What v0.4-alpha intentionally does NOT do:
|
|
26
|
+
* - LLM-backed classifier (rule-based only)
|
|
27
|
+
* - Multi-step plans (one prompt → one specialist)
|
|
28
|
+
* - Cross-session memory (Conductor sees only the current prompt)
|
|
29
|
+
* - Mid-session redirection (once delegated, the specialist owns the session)
|
|
30
|
+
*
|
|
31
|
+
* Adding any of those before v0.4-stable rc.1 violates the "rc.N is feature-
|
|
32
|
+
* frozen, beta.N+1 may only harden or fix" rule.
|
|
33
|
+
*
|
|
34
|
+
* @see docs/CONDUCTOR.md for the full design + roadmap from scaffold → stable
|
|
35
|
+
* @see memory/project_decisions_log.md Decision 05 (F6 as first-class foundation)
|
|
36
|
+
*/
|
|
37
|
+
import { EventEmitter } from 'events';
|
|
38
|
+
import { randomUUID } from 'crypto';
|
|
39
|
+
const ROUTING_RULES = [
|
|
40
|
+
{
|
|
41
|
+
triggers: ['review', 'audit', 'explain', 'understand', 'analyze', 'analyse', 'why', 'what does'],
|
|
42
|
+
preferred: ['claude-code', 'codex', 'aider'],
|
|
43
|
+
reason: 'review/audit/explain tasks favor Claude Code',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
triggers: ['refactor', 'restructure', 'reorganize', 'reorganise', 'rewrite the', 'extract'],
|
|
47
|
+
preferred: ['codex', 'claude-code', 'aider'],
|
|
48
|
+
reason: 'structural refactors favor Codex',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
triggers: ['fix', 'patch', 'bugfix', 'hotfix', 'repair', 'resolve'],
|
|
52
|
+
preferred: ['aider', 'claude-code', 'codex'],
|
|
53
|
+
reason: 'targeted fixes favor Aider',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
triggers: ['scaffold', 'generate', 'create new', 'bootstrap', 'set up'],
|
|
57
|
+
preferred: ['opencode', 'claude-code', 'codex'],
|
|
58
|
+
reason: 'greenfield generation favors OpenCode',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
triggers: ['add tests', 'write tests', 'test coverage', 'unit test', 'integration test'],
|
|
62
|
+
preferred: ['codex', 'claude-code', 'aider'],
|
|
63
|
+
reason: 'test authoring favors Codex',
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
/** Default specialist when no routing rule matches. */
|
|
67
|
+
const DEFAULT_SPECIALIST = 'claude-code';
|
|
68
|
+
/**
|
|
69
|
+
* Tokenize a prompt for trigger matching. Splits on any non-alphanumeric
|
|
70
|
+
* boundary (whitespace, hyphens, punctuation), lowercases everything. So:
|
|
71
|
+
*
|
|
72
|
+
* "Bug fix" → ["bug", "fix"]
|
|
73
|
+
* "set-up auth" → ["set", "up", "auth"] (trigger "set up" still matches via phrase below)
|
|
74
|
+
* "unit-test it" → ["unit", "test", "it"]
|
|
75
|
+
* "prefix matter" → ["prefix", "matter"] (does NOT match `fix`)
|
|
76
|
+
* "hotfixes" → ["hotfixes"] (does NOT match `fix`)
|
|
77
|
+
*
|
|
78
|
+
* Multi-word triggers ("add tests", "create new") are matched against the
|
|
79
|
+
* normalized prompt directly using word boundaries — so they catch
|
|
80
|
+
* "add tests" but not "added testing harness".
|
|
81
|
+
*/
|
|
82
|
+
function tokenize(prompt) {
|
|
83
|
+
const tokens = new Set(prompt
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.split(/[^a-z0-9]+/)
|
|
86
|
+
.filter(Boolean));
|
|
87
|
+
// Normalized form for multi-word phrase matching: ANY non-alphanumeric char
|
|
88
|
+
// (hyphens, underscores, slashes, dots, colons, commas, etc.) becomes a space,
|
|
89
|
+
// then collapse runs of whitespace. This matches the tokenizer's split rule
|
|
90
|
+
// exactly, so multi-word triggers like "add tests" catch all of:
|
|
91
|
+
// "add tests" "add-tests" "add_tests" "add/tests" "add:tests" "add.tests"
|
|
92
|
+
// Note: must NOT collapse the leading/trailing spaces — they're load-bearing
|
|
93
|
+
// for the boundary check below (`.includes(' ' + t + ' ')`).
|
|
94
|
+
const normalized = ' ' + prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() + ' ';
|
|
95
|
+
return { tokens, normalized };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Test whether a routing trigger matches a prompt. Single-word triggers must
|
|
99
|
+
* appear as whole tokens (no substring matches). Multi-word triggers must
|
|
100
|
+
* appear as a phrase in the normalized prompt (with leading/trailing spaces
|
|
101
|
+
* to enforce word boundaries on the ends).
|
|
102
|
+
*/
|
|
103
|
+
function triggerMatches(trigger, tokens, normalized) {
|
|
104
|
+
const t = trigger.toLowerCase().trim();
|
|
105
|
+
if (!t)
|
|
106
|
+
return false;
|
|
107
|
+
if (t.includes(' ')) {
|
|
108
|
+
return normalized.includes(' ' + t + ' ');
|
|
109
|
+
}
|
|
110
|
+
return tokens.has(t);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Pick the right specialist for a given prompt.
|
|
114
|
+
*
|
|
115
|
+
* Algorithm:
|
|
116
|
+
* 1. Tokenize the prompt (whole-word matching, no substring false-positives).
|
|
117
|
+
* 2. Walk routing rules in order. Stop at the first rule whose triggers match.
|
|
118
|
+
* 3. Within that rule, return the first preferred adapter that's installed.
|
|
119
|
+
* 4. If that rule had a match but none of its preferred adapters are
|
|
120
|
+
* installed, return DEFAULT_SPECIALIST (or first installed) with a
|
|
121
|
+
* HONEST reason that names the matched rule and the fallback.
|
|
122
|
+
* 5. If no rule matched at all, return DEFAULT_SPECIALIST (or first
|
|
123
|
+
* installed) with a "no intent matched" reason.
|
|
124
|
+
*
|
|
125
|
+
* Returns null if NO specialist is installed (caller must handle).
|
|
126
|
+
*
|
|
127
|
+
* Known limitation: a prompt with multiple intents (e.g. "fix the bug and
|
|
128
|
+
* add tests") routes by the first matching rule, not the dominant intent.
|
|
129
|
+
* The v0.4 LLM classifier inherently handles dominance via confidence
|
|
130
|
+
* scoring; the rule-based path here keeps the deterministic-priority
|
|
131
|
+
* contract instead.
|
|
132
|
+
*/
|
|
133
|
+
export function pickSpecialist(prompt, installedAgentIds) {
|
|
134
|
+
if (installedAgentIds.length === 0) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const installedSet = new Set(installedAgentIds);
|
|
138
|
+
const { tokens, normalized } = tokenize(prompt);
|
|
139
|
+
for (const rule of ROUTING_RULES) {
|
|
140
|
+
const matches = rule.triggers.some((t) => triggerMatches(t, tokens, normalized));
|
|
141
|
+
if (!matches)
|
|
142
|
+
continue;
|
|
143
|
+
// Rule matched. Walk preferred specialists in order.
|
|
144
|
+
for (const candidate of rule.preferred) {
|
|
145
|
+
if (installedSet.has(candidate)) {
|
|
146
|
+
return { agentId: candidate, reason: rule.reason };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Rule matched but none of its preferred specialists are installed.
|
|
150
|
+
// Fall through with an HONEST reason that names the matched rule + fallback,
|
|
151
|
+
// so the audit log records what actually happened (Codex review caught
|
|
152
|
+
// the prior version saying "default routing" here, which was a lie).
|
|
153
|
+
const fallbackId = installedSet.has(DEFAULT_SPECIALIST) ? DEFAULT_SPECIALIST : installedAgentIds[0];
|
|
154
|
+
return {
|
|
155
|
+
agentId: fallbackId,
|
|
156
|
+
reason: `${rule.reason} — but none of [${rule.preferred.join(', ')}] installed; falling back to ${fallbackId}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// No rule matched. Default routing.
|
|
160
|
+
if (installedSet.has(DEFAULT_SPECIALIST)) {
|
|
161
|
+
return { agentId: DEFAULT_SPECIALIST, reason: 'default routing (no specific intent matched)' };
|
|
162
|
+
}
|
|
163
|
+
return { agentId: installedAgentIds[0], reason: 'fallback to first available specialist' };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build the Conductor adapter. The lookup function is injected so we can
|
|
167
|
+
* register against the same registry without creating an import cycle.
|
|
168
|
+
*
|
|
169
|
+
* IMPORTANT: the lookup is called at spawn time, NOT module-load time, so
|
|
170
|
+
* it sees the fully-populated registry including any user-config adapters.
|
|
171
|
+
*/
|
|
172
|
+
export function buildConductorAdapter(lookup) {
|
|
173
|
+
const adapter = {
|
|
174
|
+
id: 'conductor',
|
|
175
|
+
label: 'Conductor (codehere)',
|
|
176
|
+
// Conductor doesn't run as its own OS process — there's nothing to detect
|
|
177
|
+
// via `ps`. The processPattern returned here will never match, by design.
|
|
178
|
+
processPattern: /__conductor_never_matches__/,
|
|
179
|
+
isInstalled() {
|
|
180
|
+
// Conductor is "installed" iff at least one specialist is installed.
|
|
181
|
+
// Anything else would be a confusing UX (offer Conductor when it has
|
|
182
|
+
// nothing to delegate to).
|
|
183
|
+
return lookup().some((a) => a.id !== 'conductor' && a.isInstalled());
|
|
184
|
+
},
|
|
185
|
+
checkReadiness() {
|
|
186
|
+
const specialists = lookup().filter((a) => a.id !== 'conductor');
|
|
187
|
+
const installedSpecialists = specialists.filter((a) => a.isInstalled());
|
|
188
|
+
if (installedSpecialists.length === 0) {
|
|
189
|
+
return {
|
|
190
|
+
id: 'conductor',
|
|
191
|
+
label: 'Conductor (codehere)',
|
|
192
|
+
state: 'needs_install',
|
|
193
|
+
installed: false,
|
|
194
|
+
installHint: {
|
|
195
|
+
command: 'npm install -g @anthropic-ai/claude-code',
|
|
196
|
+
url: 'https://github.com/codehere/codehere#install',
|
|
197
|
+
},
|
|
198
|
+
authHint: {
|
|
199
|
+
detail: 'Conductor delegates to specialists. Install at least one (Claude Code, Codex, Aider, or OpenCode) and Conductor becomes available automatically.',
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
id: 'conductor',
|
|
205
|
+
label: 'Conductor (codehere)',
|
|
206
|
+
state: 'ready',
|
|
207
|
+
installed: true,
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
spawn(prompt, options) {
|
|
211
|
+
return spawnConductor(prompt, options ?? {}, lookup);
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
return adapter;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Spawn the Conductor session. Does not fork a process — instead picks a
|
|
218
|
+
* specialist, spawns it, and proxies its events through with a single
|
|
219
|
+
* "delegated" event prepended to the buffer.
|
|
220
|
+
*/
|
|
221
|
+
function spawnConductor(prompt, options, lookup) {
|
|
222
|
+
const sessionId = randomUUID();
|
|
223
|
+
const startedAt = Date.now();
|
|
224
|
+
const emitter = new EventEmitter();
|
|
225
|
+
const buffer = [];
|
|
226
|
+
let statusValue = 'running';
|
|
227
|
+
// Look up installed specialists (excluding Conductor itself to avoid recursion)
|
|
228
|
+
const installedAgentIds = lookup()
|
|
229
|
+
.filter((a) => a.id !== 'conductor' && a.isInstalled())
|
|
230
|
+
.map((a) => a.id);
|
|
231
|
+
const decision = pickSpecialist(prompt, installedAgentIds);
|
|
232
|
+
// If nothing's installed, emit an error event and return a non-running session.
|
|
233
|
+
if (!decision) {
|
|
234
|
+
statusValue = 'error';
|
|
235
|
+
const errEvent = {
|
|
236
|
+
type: 'error',
|
|
237
|
+
timestamp: Date.now(),
|
|
238
|
+
data: {
|
|
239
|
+
message: 'Conductor has no specialists to delegate to. Install at least one of: Claude Code, Codex, Aider, OpenCode.',
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
buffer.push(errEvent);
|
|
243
|
+
queueMicrotask(() => emitter.emit('event', errEvent));
|
|
244
|
+
return synthesizeSession({ sessionId, startedAt, prompt, statusValue, emitter, buffer });
|
|
245
|
+
}
|
|
246
|
+
// Emit the routing-decision event FIRST so the audit log records WHY this
|
|
247
|
+
// specialist was chosen. Layer 7 agent-provenance — every action attributable.
|
|
248
|
+
const routingEvent = {
|
|
249
|
+
type: 'codehere:conductor-routing',
|
|
250
|
+
timestamp: Date.now(),
|
|
251
|
+
data: {
|
|
252
|
+
conductorSessionId: sessionId,
|
|
253
|
+
delegatedTo: decision.agentId,
|
|
254
|
+
reason: decision.reason,
|
|
255
|
+
promptExcerpt: prompt.slice(0, 200),
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
buffer.push(routingEvent);
|
|
259
|
+
queueMicrotask(() => emitter.emit('event', routingEvent));
|
|
260
|
+
// Find the chosen specialist adapter and spawn against it.
|
|
261
|
+
const specialist = lookup().find((a) => a.id === decision.agentId);
|
|
262
|
+
if (!specialist) {
|
|
263
|
+
// Defensive: pickSpecialist returned an ID not in the registry. Shouldn't
|
|
264
|
+
// happen if installedAgentIds was derived from lookup(), but guard anyway.
|
|
265
|
+
statusValue = 'error';
|
|
266
|
+
const errEvent = {
|
|
267
|
+
type: 'error',
|
|
268
|
+
timestamp: Date.now(),
|
|
269
|
+
data: { message: `Conductor picked '${decision.agentId}' but no adapter with that ID is registered.` },
|
|
270
|
+
};
|
|
271
|
+
buffer.push(errEvent);
|
|
272
|
+
queueMicrotask(() => emitter.emit('event', errEvent));
|
|
273
|
+
return synthesizeSession({ sessionId, startedAt, prompt, statusValue, emitter, buffer });
|
|
274
|
+
}
|
|
275
|
+
// Spawn the specialist session and proxy every event through.
|
|
276
|
+
const specialistSession = specialist.spawn(prompt, options);
|
|
277
|
+
let stepsSnapshot = [];
|
|
278
|
+
let artifactsSnapshot;
|
|
279
|
+
specialistSession.subscribe((evt) => {
|
|
280
|
+
buffer.push(evt);
|
|
281
|
+
emitter.emit('event', evt);
|
|
282
|
+
// Mirror progress + artifacts so PM-UX layers see them on the Conductor session, too.
|
|
283
|
+
if (specialistSession.steps)
|
|
284
|
+
stepsSnapshot = specialistSession.steps;
|
|
285
|
+
if (specialistSession.artifacts)
|
|
286
|
+
artifactsSnapshot = specialistSession.artifacts;
|
|
287
|
+
if (evt.type === 'codehere:complete' || evt.type === 'codehere:error') {
|
|
288
|
+
statusValue = specialistSession.status;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
sessionId,
|
|
293
|
+
startedAt,
|
|
294
|
+
prompt,
|
|
295
|
+
agent: 'conductor',
|
|
296
|
+
get status() {
|
|
297
|
+
return statusValue;
|
|
298
|
+
},
|
|
299
|
+
get steps() {
|
|
300
|
+
return stepsSnapshot;
|
|
301
|
+
},
|
|
302
|
+
get artifacts() {
|
|
303
|
+
return artifactsSnapshot;
|
|
304
|
+
},
|
|
305
|
+
canAppendMessage: false, // v0.4-alpha doesn't support mid-session redirection
|
|
306
|
+
subscribe(handler) {
|
|
307
|
+
for (const e of buffer)
|
|
308
|
+
handler(e);
|
|
309
|
+
emitter.on('event', handler);
|
|
310
|
+
return () => emitter.off('event', handler);
|
|
311
|
+
},
|
|
312
|
+
kill() {
|
|
313
|
+
specialistSession.kill();
|
|
314
|
+
statusValue = 'killed';
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** Build a TaskSession that just replays an event buffer — used for error states. */
|
|
319
|
+
function synthesizeSession(args) {
|
|
320
|
+
const { sessionId, startedAt, prompt, statusValue, emitter, buffer } = args;
|
|
321
|
+
return {
|
|
322
|
+
sessionId,
|
|
323
|
+
startedAt,
|
|
324
|
+
prompt,
|
|
325
|
+
agent: 'conductor',
|
|
326
|
+
get status() {
|
|
327
|
+
return statusValue;
|
|
328
|
+
},
|
|
329
|
+
subscribe(handler) {
|
|
330
|
+
for (const e of buffer)
|
|
331
|
+
handler(e);
|
|
332
|
+
emitter.on('event', handler);
|
|
333
|
+
return () => emitter.off('event', handler);
|
|
334
|
+
},
|
|
335
|
+
kill() {
|
|
336
|
+
/* nothing to kill — never started */
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
//# sourceMappingURL=conductor-adapter.js.map
|
|
@@ -18,6 +18,7 @@ import { CodexAdapter } from './codex-adapter.js';
|
|
|
18
18
|
import { AiderAdapter } from './aider-adapter.js';
|
|
19
19
|
import { OpenCodeAdapter } from './opencode-adapter.js';
|
|
20
20
|
import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js';
|
|
21
|
+
import { buildConductorAdapter } from './conductor-adapter.js';
|
|
21
22
|
import { seedAgentsConfig } from './agents-config-seed.js';
|
|
22
23
|
import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
23
24
|
import { appendSpawned, appendCompleted, loadRecent } from './session-persistence.js';
|
|
@@ -37,6 +38,11 @@ const REGISTRY = [
|
|
|
37
38
|
// popular CLI agents above. Users can add/edit/remove entries freely.
|
|
38
39
|
...loadAgentsConfig().map(buildGenericAdapter),
|
|
39
40
|
];
|
|
41
|
+
// F6 Conductor — the meta-agent. Registered LAST so it sees the full registry
|
|
42
|
+
// at lookup time. The lookup function is closed over `REGISTRY` so it always
|
|
43
|
+
// returns the current populated array (including any future user adapters).
|
|
44
|
+
// Conductor is appended to the same array so UIs that iterate REGISTRY see it.
|
|
45
|
+
REGISTRY.push(buildConductorAdapter(() => REGISTRY));
|
|
40
46
|
export function availableAgents() {
|
|
41
47
|
return REGISTRY.map((a) => ({ id: a.id, label: a.label, installed: a.isInstalled() }));
|
|
42
48
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether a given host:port is already serving a codehere instance.
|
|
3
|
+
*
|
|
4
|
+
* Used by `codehere start` when the listen() fails with EADDRINUSE: we'd
|
|
5
|
+
* rather give the user a friendly "codehere already running, opening browser"
|
|
6
|
+
* than a Node stack trace. If something ELSE holds the port we tell them to
|
|
7
|
+
* pick a different one.
|
|
8
|
+
*
|
|
9
|
+
* Approach: hit the existing server's /health endpoint. The codehere MCP
|
|
10
|
+
* server returns a JSON body with `server: 'codehere-mcp'` — no other tool
|
|
11
|
+
* uses that exact identifier. Generous 1.5-second timeout so a slow loopback
|
|
12
|
+
* doesn't false-negative.
|
|
13
|
+
*/
|
|
14
|
+
export async function isCodehereOnPort(host, port, timeoutMs = 1500) {
|
|
15
|
+
const ac = new AbortController();
|
|
16
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`http://${host}:${port}/health`, { signal: ac.signal });
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
return { isCodehere: false };
|
|
21
|
+
const body = (await res.json());
|
|
22
|
+
if (body?.server === 'codehere-mcp') {
|
|
23
|
+
return { isCodehere: true, version: body.version };
|
|
24
|
+
}
|
|
25
|
+
return { isCodehere: false };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Connection refused, timeout, malformed response — anything that's not
|
|
29
|
+
// a positive codehere identification. Treat as "not codehere."
|
|
30
|
+
return { isCodehere: false };
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=probe-port.js.map
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive REPL for codehere.
|
|
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.
|
|
8
|
+
*
|
|
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.
|
|
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
|
|
24
|
+
*/
|
|
25
|
+
import readline from 'readline';
|
|
26
|
+
import { colors, newline } from '../../ui.js';
|
|
27
|
+
import { getVersion } from '../../utils/version.js';
|
|
28
|
+
import { SessionManager, availableAgents } from '../../infrastructure/orchestration/session-manager.js';
|
|
29
|
+
const SLASH_COMMANDS = [
|
|
30
|
+
// Task commands · describe the work, the Conductor routes
|
|
31
|
+
{
|
|
32
|
+
name: 'fix',
|
|
33
|
+
aliases: ['edit'],
|
|
34
|
+
args: '<file> <instruction>',
|
|
35
|
+
description: 'fix or edit a file (Conductor routes to Aider by default)',
|
|
36
|
+
action: 'task',
|
|
37
|
+
promptTemplate: (args) => {
|
|
38
|
+
const parts = args.split(/\s+/);
|
|
39
|
+
const file = parts[0];
|
|
40
|
+
const instr = parts.slice(1).join(' ');
|
|
41
|
+
return `fix ${file}: ${instr}`;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'explain',
|
|
46
|
+
aliases: ['e'],
|
|
47
|
+
args: '<file>',
|
|
48
|
+
description: 'explain a file (Conductor routes to Claude Code by default)',
|
|
49
|
+
action: 'task',
|
|
50
|
+
promptTemplate: (args) => `explain this file in plain English: ${args}`,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'review',
|
|
54
|
+
aliases: ['r'],
|
|
55
|
+
args: '<file>',
|
|
56
|
+
description: 'review a file for bugs and risks (routes to Claude Code)',
|
|
57
|
+
action: 'task',
|
|
58
|
+
promptTemplate: (args) => `review ${args} for bugs, security issues, and risks. List concrete findings.`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'plan',
|
|
62
|
+
aliases: ['p'],
|
|
63
|
+
args: '<instruction>',
|
|
64
|
+
description: 'plan a complex task into steps (routes to Codex/Claude Code)',
|
|
65
|
+
action: 'task',
|
|
66
|
+
promptTemplate: (args) => `plan: ${args}. Break down into concrete steps before any execution.`,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'test',
|
|
70
|
+
args: '<file or area>',
|
|
71
|
+
description: 'add or write tests (routes to Codex)',
|
|
72
|
+
action: 'task',
|
|
73
|
+
promptTemplate: (args) => `add tests for ${args}. Cover edge cases and likely failure modes.`,
|
|
74
|
+
},
|
|
75
|
+
// Meta · don't spawn agents · run locally
|
|
76
|
+
{ name: 'agents', aliases: ['a'], description: 'list installed specialists + routing rules', action: 'meta' },
|
|
77
|
+
{ name: 'status', aliases: ['s'], description: 'last 24h activity from the audit log', action: 'meta' },
|
|
78
|
+
{ name: 'trust', args: '[path]', description: 'scan a path for security issues (default: current dir)', action: 'meta' },
|
|
79
|
+
{ name: 'history', aliases: ['hist'], description: 'recent prompts in this session', action: 'meta' },
|
|
80
|
+
{ name: 'web', description: 'launch the web UI at :3773 (in another terminal)', action: 'meta' },
|
|
81
|
+
{ name: 'undo', description: 'undo the last edit (queued for v0.5 · F3 memory layer)', action: 'meta' },
|
|
82
|
+
{ name: 'cost', description: 'usage / cost (alias for /status in v0.3)', action: 'meta' },
|
|
83
|
+
{ name: 'clear', description: 'clear the screen', action: 'meta' },
|
|
84
|
+
{ name: 'help', aliases: ['?'], description: 'show this help', action: 'meta' },
|
|
85
|
+
{ name: 'quit', aliases: ['exit', 'q'], description: 'exit (Ctrl+C / Ctrl+D also work)', action: 'meta' },
|
|
86
|
+
];
|
|
87
|
+
function findSlashCommand(input) {
|
|
88
|
+
const parts = input.trim().split(/\s+/);
|
|
89
|
+
const head = parts[0].toLowerCase();
|
|
90
|
+
for (const cmd of SLASH_COMMANDS) {
|
|
91
|
+
if (cmd.name === head || (cmd.aliases ?? []).includes(head)) {
|
|
92
|
+
return { command: cmd, args: parts.slice(1).join(' ').trim() };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
export async function runInteractiveRepl(options = {}) {
|
|
98
|
+
const cwd = options.cwd ?? process.cwd();
|
|
99
|
+
const history = [];
|
|
100
|
+
newline();
|
|
101
|
+
console.log(colors.bold('codehere ') + colors.dim('v' + getVersion()));
|
|
102
|
+
console.log(colors.dim('Talk to your AI coding agents. Type a task to delegate; or use a /command.'));
|
|
103
|
+
newline();
|
|
104
|
+
console.log(colors.dim(' /fix /explain /review /plan delegate a task; Conductor picks the specialist'));
|
|
105
|
+
console.log(colors.dim(' /agents /status /trust /history show state, no agent spawn'));
|
|
106
|
+
console.log(colors.dim(' /help full command list'));
|
|
107
|
+
console.log(colors.dim(' ↑/↓ history · Ctrl+L clear · Ctrl+C / Ctrl+D exit'));
|
|
108
|
+
newline();
|
|
109
|
+
const installed = availableAgents().filter((a) => a.installed);
|
|
110
|
+
if (installed.length === 0) {
|
|
111
|
+
console.log(colors.yellow(' No specialist agents installed. Install at least one to delegate tasks:'));
|
|
112
|
+
console.log(colors.dim(' npm install -g @anthropic-ai/claude-code'));
|
|
113
|
+
console.log(colors.dim(' npm install -g @openai/codex'));
|
|
114
|
+
console.log(colors.dim(' brew install aider'));
|
|
115
|
+
console.log(colors.dim(' curl -fsSL https://opencode.ai/install | bash'));
|
|
116
|
+
newline();
|
|
117
|
+
console.log(colors.dim('You can still use ') +
|
|
118
|
+
colors.cyan('/status') +
|
|
119
|
+
colors.dim(', ') +
|
|
120
|
+
colors.cyan('codehere trust .') +
|
|
121
|
+
colors.dim(', and the safety hook here.'));
|
|
122
|
+
newline();
|
|
123
|
+
}
|
|
124
|
+
const rl = readline.createInterface({
|
|
125
|
+
input: process.stdin,
|
|
126
|
+
output: process.stdout,
|
|
127
|
+
prompt: colors.cyan('› '),
|
|
128
|
+
terminal: true,
|
|
129
|
+
historySize: 200,
|
|
130
|
+
});
|
|
131
|
+
const manager = SessionManager.getInstance();
|
|
132
|
+
const promptNext = () => {
|
|
133
|
+
rl.setPrompt(colors.cyan('› '));
|
|
134
|
+
rl.prompt();
|
|
135
|
+
};
|
|
136
|
+
rl.on('close', () => {
|
|
137
|
+
newline();
|
|
138
|
+
console.log(colors.dim('Goodbye.'));
|
|
139
|
+
process.exit(0);
|
|
140
|
+
});
|
|
141
|
+
rl.on('SIGINT', () => {
|
|
142
|
+
rl.close();
|
|
143
|
+
});
|
|
144
|
+
promptNext();
|
|
145
|
+
for await (const line of rl) {
|
|
146
|
+
const trimmed = line.trim();
|
|
147
|
+
if (!trimmed) {
|
|
148
|
+
promptNext();
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
history.push(trimmed);
|
|
152
|
+
// Slash commands first
|
|
153
|
+
if (trimmed.startsWith('/')) {
|
|
154
|
+
const found = findSlashCommand(trimmed.slice(1));
|
|
155
|
+
if (!found) {
|
|
156
|
+
console.log(colors.yellow(` Unknown command: ${trimmed.split(/\s+/)[0]}`));
|
|
157
|
+
console.log(colors.dim(' Run /help to see all commands.'));
|
|
158
|
+
promptNext();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const { command, args } = found;
|
|
162
|
+
switch (command.name) {
|
|
163
|
+
case 'quit':
|
|
164
|
+
rl.close();
|
|
165
|
+
return;
|
|
166
|
+
case 'help':
|
|
167
|
+
printSlashCommandHelp();
|
|
168
|
+
break;
|
|
169
|
+
case 'clear':
|
|
170
|
+
console.clear();
|
|
171
|
+
newline();
|
|
172
|
+
console.log(colors.bold('codehere ') + colors.dim('v' + getVersion()));
|
|
173
|
+
newline();
|
|
174
|
+
break;
|
|
175
|
+
case 'agents':
|
|
176
|
+
printAgentsAndRoutingRules();
|
|
177
|
+
break;
|
|
178
|
+
case 'status':
|
|
179
|
+
await printStatusSummary();
|
|
180
|
+
break;
|
|
181
|
+
case 'history':
|
|
182
|
+
printHistory(history);
|
|
183
|
+
break;
|
|
184
|
+
case 'web':
|
|
185
|
+
console.log(colors.dim(' Tip: run ') +
|
|
186
|
+
colors.cyan('codehere start') +
|
|
187
|
+
colors.dim(' in another terminal to launch the web UI at http://127.0.0.1:3773'));
|
|
188
|
+
break;
|
|
189
|
+
case 'trust':
|
|
190
|
+
await runTrustScan(args || '.');
|
|
191
|
+
break;
|
|
192
|
+
case 'cost':
|
|
193
|
+
// v0.3 doesn't separate cost tracking — show the same dashboard as /status
|
|
194
|
+
await printStatusSummary();
|
|
195
|
+
break;
|
|
196
|
+
case 'undo':
|
|
197
|
+
console.log(colors.dim(' /undo is queued for v0.5 (F3 memory layer).'));
|
|
198
|
+
console.log(colors.dim(' For now, use git: ') + colors.cyan('git diff') + colors.dim(' / ') + colors.cyan('git checkout -- <file>'));
|
|
199
|
+
break;
|
|
200
|
+
default: {
|
|
201
|
+
// Task slash command (/fix, /explain, /review, /plan, /test) → Conductor
|
|
202
|
+
if (command.action !== 'task' || !command.promptTemplate)
|
|
203
|
+
break;
|
|
204
|
+
if (!args) {
|
|
205
|
+
console.log(colors.yellow(` Usage: /${command.name}${command.args ? ' ' + command.args : ''}`));
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
if (installed.length === 0) {
|
|
209
|
+
console.log(colors.yellow(' No specialist installed — install one and try again.'));
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
const promptText = command.promptTemplate(args);
|
|
213
|
+
try {
|
|
214
|
+
await runTask(promptText, cwd, manager);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
console.log(colors.red(' ✗ ') + (err instanceof Error ? err.message : String(err)));
|
|
218
|
+
}
|
|
219
|
+
newline();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
promptNext();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// Legacy non-slash syntax for backwards-compat with v0.2 muscle memory
|
|
226
|
+
// (commit 796d4b7's REPL accepted these forms without /).
|
|
227
|
+
const lower = trimmed.toLowerCase();
|
|
228
|
+
const legacyMatch = lower.startsWith('explain ') || lower.startsWith('e ')
|
|
229
|
+
? { name: 'explain', argsAt: lower.startsWith('explain ') ? 8 : 2 }
|
|
230
|
+
: lower.startsWith('fix ') || lower.startsWith('edit ')
|
|
231
|
+
? { name: 'fix', argsAt: lower.startsWith('fix ') ? 4 : 5 }
|
|
232
|
+
: lower.startsWith('review ')
|
|
233
|
+
? { name: 'review', argsAt: 7 }
|
|
234
|
+
: lower.startsWith('plan ')
|
|
235
|
+
? { name: 'plan', argsAt: 5 }
|
|
236
|
+
: null;
|
|
237
|
+
if (legacyMatch) {
|
|
238
|
+
const cmd = SLASH_COMMANDS.find((c) => c.name === legacyMatch.name);
|
|
239
|
+
if (cmd?.promptTemplate) {
|
|
240
|
+
const args = trimmed.substring(legacyMatch.argsAt).trim();
|
|
241
|
+
if (!args) {
|
|
242
|
+
console.log(colors.yellow(` Usage: /${cmd.name}${cmd.args ? ' ' + cmd.args : ''}`));
|
|
243
|
+
}
|
|
244
|
+
else if (installed.length === 0) {
|
|
245
|
+
console.log(colors.yellow(' No specialist installed — install one and try again.'));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
try {
|
|
249
|
+
await runTask(cmd.promptTemplate(args), cwd, manager);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
console.log(colors.red(' ✗ ') + (err instanceof Error ? err.message : String(err)));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
newline();
|
|
256
|
+
promptNext();
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Plain text → delegate via Conductor
|
|
261
|
+
if (installed.length === 0) {
|
|
262
|
+
console.log(colors.yellow(' No specialist installed — install one and try again.'));
|
|
263
|
+
promptNext();
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await runTask(trimmed, cwd, manager);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
console.log(colors.red(' ✗ ') + (err instanceof Error ? err.message : String(err)));
|
|
271
|
+
}
|
|
272
|
+
newline();
|
|
273
|
+
promptNext();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Run `codehere trust <path>` inline. Spawns the trust command as a child
|
|
278
|
+
* process so the existing rendering / scoring logic is reused as-is.
|
|
279
|
+
*/
|
|
280
|
+
async function runTrustScan(target) {
|
|
281
|
+
const { spawn } = await import('child_process');
|
|
282
|
+
const { fileURLToPath } = await import('url');
|
|
283
|
+
const { join, dirname } = await import('path');
|
|
284
|
+
// dist/presentation/cli/repl.js → walk up to dist/index.js
|
|
285
|
+
const here = fileURLToPath(import.meta.url);
|
|
286
|
+
const cliPath = join(dirname(here), '..', '..', 'index.js');
|
|
287
|
+
await new Promise((resolve) => {
|
|
288
|
+
const child = spawn('node', [cliPath, 'trust', target], { stdio: 'inherit' });
|
|
289
|
+
child.on('close', () => resolve());
|
|
290
|
+
child.on('error', (err) => {
|
|
291
|
+
console.log(colors.red(' ✗ ') + err.message);
|
|
292
|
+
resolve();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function printSlashCommandHelp() {
|
|
297
|
+
const formatRow = (cmd) => {
|
|
298
|
+
const aliases = cmd.aliases?.length ? colors.dim(' (/' + cmd.aliases.join(', /') + ')') : '';
|
|
299
|
+
const cmdLabel = '/' + cmd.name + (cmd.args ? ' ' + cmd.args : '');
|
|
300
|
+
console.log(' ' + colors.cyan(cmdLabel.padEnd(34)) + colors.dim(cmd.description) + aliases);
|
|
301
|
+
};
|
|
302
|
+
newline();
|
|
303
|
+
console.log(colors.bold('Slash commands'));
|
|
304
|
+
console.log(colors.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
305
|
+
newline();
|
|
306
|
+
console.log(colors.bold(' Tasks') + colors.dim(' — describe the work; Conductor routes to the right specialist'));
|
|
307
|
+
for (const cmd of SLASH_COMMANDS.filter((c) => c.action === 'task'))
|
|
308
|
+
formatRow(cmd);
|
|
309
|
+
newline();
|
|
310
|
+
console.log(colors.bold(' Meta') + colors.dim(' — run locally, no agent spawn'));
|
|
311
|
+
for (const cmd of SLASH_COMMANDS.filter((c) => c.action === 'meta'))
|
|
312
|
+
formatRow(cmd);
|
|
313
|
+
newline();
|
|
314
|
+
console.log(colors.bold(' Or just type'));
|
|
315
|
+
console.log(colors.dim(' Plain text without / → Conductor routes (e.g. "review src/auth.ts" → claude-code)'));
|
|
316
|
+
console.log(colors.dim(' Legacy v0.2 syntax: ') + colors.cyan('explain src/auth.ts') + colors.dim(' / ') + colors.cyan('fix file "instr"') + colors.dim(' also work without /'));
|
|
317
|
+
newline();
|
|
318
|
+
}
|
|
319
|
+
function printHistory(history) {
|
|
320
|
+
newline();
|
|
321
|
+
if (history.length === 0) {
|
|
322
|
+
console.log(colors.dim(' No prompts yet in this session.'));
|
|
323
|
+
newline();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
console.log(colors.bold(' Recent prompts:'));
|
|
327
|
+
history.slice(-10).forEach((h, i) => {
|
|
328
|
+
const preview = h.length > 70 ? h.slice(0, 67) + '…' : h;
|
|
329
|
+
console.log(colors.dim(` ${(i + 1).toString().padStart(2, ' ')}. `) + preview);
|
|
330
|
+
});
|
|
331
|
+
newline();
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Run a single task and exit — bypass the REPL prompt loop.
|
|
335
|
+
* Use case: `codehere "review src/auth.ts"` or scripts piping a task in.
|
|
336
|
+
*/
|
|
337
|
+
export async function runOneShot(prompt, cwd = process.cwd()) {
|
|
338
|
+
const installed = availableAgents().filter((a) => a.installed);
|
|
339
|
+
if (installed.length === 0) {
|
|
340
|
+
console.log(colors.yellow(' No specialist installed. Install one (see `codehere setup`) and try again.'));
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
const manager = SessionManager.getInstance();
|
|
344
|
+
try {
|
|
345
|
+
await runTask(prompt, cwd, manager);
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
console.log(colors.red(' ✗ ') + (err instanceof Error ? err.message : String(err)));
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function runTask(prompt, cwd, manager) {
|
|
353
|
+
const session = manager.spawn(prompt, 'conductor', { cwd });
|
|
354
|
+
const startedLabel = colors.dim(' ┌─ ') + colors.bold(session.sessionId.slice(0, 8)) + colors.dim(' · ' + new Date(session.startedAt).toLocaleTimeString());
|
|
355
|
+
console.log(startedLabel);
|
|
356
|
+
await new Promise((resolve) => {
|
|
357
|
+
const unsubscribe = session.subscribe((event) => {
|
|
358
|
+
renderEvent(event);
|
|
359
|
+
if (event.type === 'codehere:complete' || event.type === 'codehere:error' || event.type === 'exit') {
|
|
360
|
+
unsubscribe();
|
|
361
|
+
resolve();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
const finalStatus = session.status;
|
|
366
|
+
const verdict = finalStatus === 'completed' ? colors.green(' └─ done') : finalStatus === 'killed' ? colors.yellow(' └─ killed') : colors.red(' └─ ' + finalStatus);
|
|
367
|
+
console.log(verdict);
|
|
368
|
+
}
|
|
369
|
+
function renderEvent(event) {
|
|
370
|
+
switch (event.type) {
|
|
371
|
+
case 'codehere:conductor-routing': {
|
|
372
|
+
const data = event.data;
|
|
373
|
+
console.log(colors.dim(' │ ') +
|
|
374
|
+
colors.bold('routing → ') +
|
|
375
|
+
colors.cyan(data.delegatedTo ?? '?') +
|
|
376
|
+
colors.dim(' · ' + (data.reason ?? '')));
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
case 'codehere:steps': {
|
|
380
|
+
// Step snapshots replay frequently — skip the noisy ones; the UI handles them
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
case 'stdout':
|
|
384
|
+
case 'stderr': {
|
|
385
|
+
const data = event.data;
|
|
386
|
+
const line = data?.line;
|
|
387
|
+
if (line) {
|
|
388
|
+
const text = String(line).trimEnd();
|
|
389
|
+
if (text)
|
|
390
|
+
console.log(colors.dim(' │ ') + text);
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
case 'error': {
|
|
395
|
+
const data = event.data;
|
|
396
|
+
if (data?.message)
|
|
397
|
+
console.log(colors.red(' │ ✗ ') + data.message);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 'codehere:complete':
|
|
401
|
+
case 'exit': {
|
|
402
|
+
// handled by runTask's verdict line
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
default:
|
|
406
|
+
// Other event types — quiet by default to keep the REPL readable
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function printAgentsAndRoutingRules() {
|
|
411
|
+
newline();
|
|
412
|
+
console.log(colors.bold(' Installed specialists:'));
|
|
413
|
+
const all = availableAgents();
|
|
414
|
+
for (const a of all) {
|
|
415
|
+
const tag = a.installed ? colors.green(' ✓ ') : colors.dim(' · ');
|
|
416
|
+
const status = a.installed ? '' : colors.dim(' (not installed)');
|
|
417
|
+
console.log(tag + colors.cyan(a.id.padEnd(14)) + colors.dim(a.label) + status);
|
|
418
|
+
}
|
|
419
|
+
newline();
|
|
420
|
+
console.log(colors.bold(' Routing (rule-based; LLM classifier in v0.4):'));
|
|
421
|
+
console.log(colors.dim(' review · audit · explain → claude-code'));
|
|
422
|
+
console.log(colors.dim(' refactor · restructure → codex'));
|
|
423
|
+
console.log(colors.dim(' fix · patch · bugfix → aider'));
|
|
424
|
+
console.log(colors.dim(' scaffold · generate · bootstrap → opencode'));
|
|
425
|
+
console.log(colors.dim(' add tests · write tests → codex'));
|
|
426
|
+
console.log(colors.dim(' (anything else) → claude-code'));
|
|
427
|
+
newline();
|
|
428
|
+
}
|
|
429
|
+
async function printStatusSummary() {
|
|
430
|
+
newline();
|
|
431
|
+
try {
|
|
432
|
+
const { existsSync, readFileSync } = await import('fs');
|
|
433
|
+
const { join } = await import('path');
|
|
434
|
+
const { homedir } = await import('os');
|
|
435
|
+
const auditPath = process.env.CODEHERE_AUDIT_FILE || join(homedir(), '.codehere', 'audit', 'hook-events.jsonl');
|
|
436
|
+
if (!existsSync(auditPath)) {
|
|
437
|
+
console.log(colors.dim(' No audit log yet. Run a task or `codehere hook install`.'));
|
|
438
|
+
newline();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const lines = readFileSync(auditPath, 'utf-8').split('\n').filter(Boolean).slice(-10);
|
|
442
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
443
|
+
let actions = 0;
|
|
444
|
+
let blocked = 0;
|
|
445
|
+
let trustSum = 0;
|
|
446
|
+
let trustN = 0;
|
|
447
|
+
for (const line of lines) {
|
|
448
|
+
try {
|
|
449
|
+
const entry = JSON.parse(line);
|
|
450
|
+
const ts = entry.timestamp ? Date.parse(entry.timestamp) : 0;
|
|
451
|
+
if (ts < cutoff)
|
|
452
|
+
continue;
|
|
453
|
+
actions++;
|
|
454
|
+
if (entry.blocked)
|
|
455
|
+
blocked++;
|
|
456
|
+
if (typeof entry.riskScore === 'number') {
|
|
457
|
+
trustSum += 100 - entry.riskScore;
|
|
458
|
+
trustN++;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
/* skip malformed line */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const avgTrust = trustN > 0 ? Math.round(trustSum / trustN) : null;
|
|
466
|
+
console.log(colors.bold(' Last 24h:'));
|
|
467
|
+
console.log(` ${actions} actions · ${blocked} blocked${avgTrust !== null ? ` · avg trust ${avgTrust}` : ''}`);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
console.log(colors.dim(' Could not read audit log: ' + (err instanceof Error ? err.message : String(err))));
|
|
471
|
+
}
|
|
472
|
+
newline();
|
|
473
|
+
}
|
|
474
|
+
//# sourceMappingURL=repl.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codehere",
|
|
3
|
-
"version": "0.3.0-rc.
|
|
3
|
+
"version": "0.3.0-rc.10",
|
|
4
4
|
"description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, and OpenCode from one local web UI — every AI action traced, every file scored.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|