codehere 0.3.0-rc.3 → 0.3.0-rc.5

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 CHANGED
@@ -354,10 +354,30 @@ program
354
354
  const port = parseInt(options?.port ?? '3773', 10);
355
355
  const host = options?.host ?? '127.0.0.1';
356
356
  const shouldOpen = options?.open !== false;
357
+ const url = `http://${host}:${port}/`;
358
+ const openBrowser = async () => {
359
+ if (!shouldOpen)
360
+ return;
361
+ const { spawn } = await import('child_process');
362
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
363
+ try {
364
+ spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref();
365
+ }
366
+ catch {
367
+ /* browser open is best-effort */
368
+ }
369
+ };
357
370
  try {
358
371
  const { startMCPServer } = await import('./infrastructure/protocols/mcp-server-impl.js');
359
372
  const server = startMCPServer({ port, host });
360
- const url = `http://${host}:${port}/`;
373
+ // Wait for the server to either start listening or fail. The default
374
+ // Node behavior on listen() failure is an unhandled 'error' event which
375
+ // crashes the process with a stack trace. We catch it here so the user
376
+ // sees a friendly message instead.
377
+ await new Promise((resolve, reject) => {
378
+ server.once('listening', () => resolve());
379
+ server.once('error', reject);
380
+ });
361
381
  newline();
362
382
  console.log(success('codehere is running'));
363
383
  newline();
@@ -367,16 +387,7 @@ program
367
387
  newline();
368
388
  console.log(colors.dim(' Press Ctrl+C to stop'));
369
389
  newline();
370
- if (shouldOpen) {
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
- }
390
+ await openBrowser();
380
391
  process.on('SIGINT', () => {
381
392
  server.close();
382
393
  console.log(colors.dim('\ncodehere stopped'));
@@ -387,6 +398,28 @@ program
387
398
  });
388
399
  }
389
400
  catch (err) {
401
+ const code = err?.code;
402
+ if (code === 'EADDRINUSE') {
403
+ // Port is taken. Probe to find out by what — if it's a running codehere,
404
+ // we should hand the user the existing URL; if it's something else,
405
+ // tell them to pick a different port.
406
+ const { isCodehereOnPort } = await import('./infrastructure/protocols/probe-port.js');
407
+ const existing = await isCodehereOnPort(host, port);
408
+ newline();
409
+ if (existing.isCodehere) {
410
+ console.log(success(`codehere is already running at ${url}`));
411
+ if (existing.version) {
412
+ console.log(colors.dim(` version: ${existing.version}`));
413
+ }
414
+ newline();
415
+ console.log(colors.dim(' Opening browser...'));
416
+ await openBrowser();
417
+ process.exit(0);
418
+ }
419
+ console.error(error(`Port ${port} is in use by another process.`));
420
+ console.error(colors.dim(` Try: codehere start --port ${port + 1}`));
421
+ process.exit(1);
422
+ }
390
423
  console.error(error('Failed to start:'), err instanceof Error ? err.message : String(err));
391
424
  process.exit(1);
392
425
  }
@@ -0,0 +1,277 @@
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
+ * Pick the right specialist for a given prompt.
70
+ *
71
+ * Algorithm:
72
+ * 1. Walk routing rules in order.
73
+ * 2. For the first rule whose triggers match, walk its `preferred` list.
74
+ * 3. Return the first preferred adapter that's installed.
75
+ * 4. If no rule matches OR no preferred adapter is installed, fall back to
76
+ * `DEFAULT_SPECIALIST` if it's installed; otherwise the first-installed
77
+ * adapter on the list.
78
+ *
79
+ * Returns null if NO specialist is installed (caller must handle).
80
+ */
81
+ export function pickSpecialist(prompt, installedAgentIds) {
82
+ if (installedAgentIds.length === 0) {
83
+ return null;
84
+ }
85
+ const lowered = prompt.toLowerCase();
86
+ for (const rule of ROUTING_RULES) {
87
+ const matches = rule.triggers.some((t) => lowered.includes(t));
88
+ if (!matches)
89
+ continue;
90
+ for (const candidate of rule.preferred) {
91
+ if (installedAgentIds.includes(candidate)) {
92
+ return { agentId: candidate, reason: rule.reason };
93
+ }
94
+ }
95
+ }
96
+ // No rule matched (or no preferred specialist installed) — default routing.
97
+ if (installedAgentIds.includes(DEFAULT_SPECIALIST)) {
98
+ return { agentId: DEFAULT_SPECIALIST, reason: 'default routing (no specific intent matched)' };
99
+ }
100
+ return { agentId: installedAgentIds[0], reason: 'fallback to first available specialist' };
101
+ }
102
+ /**
103
+ * Build the Conductor adapter. The lookup function is injected so we can
104
+ * register against the same registry without creating an import cycle.
105
+ *
106
+ * IMPORTANT: the lookup is called at spawn time, NOT module-load time, so
107
+ * it sees the fully-populated registry including any user-config adapters.
108
+ */
109
+ export function buildConductorAdapter(lookup) {
110
+ const adapter = {
111
+ id: 'conductor',
112
+ label: 'Conductor (codehere)',
113
+ // Conductor doesn't run as its own OS process — there's nothing to detect
114
+ // via `ps`. The processPattern returned here will never match, by design.
115
+ processPattern: /__conductor_never_matches__/,
116
+ isInstalled() {
117
+ // Conductor is "installed" iff at least one specialist is installed.
118
+ // Anything else would be a confusing UX (offer Conductor when it has
119
+ // nothing to delegate to).
120
+ return lookup().some((a) => a.id !== 'conductor' && a.isInstalled());
121
+ },
122
+ checkReadiness() {
123
+ const specialists = lookup().filter((a) => a.id !== 'conductor');
124
+ const installedSpecialists = specialists.filter((a) => a.isInstalled());
125
+ if (installedSpecialists.length === 0) {
126
+ return {
127
+ id: 'conductor',
128
+ label: 'Conductor (codehere)',
129
+ state: 'needs_install',
130
+ installed: false,
131
+ installHint: {
132
+ command: 'npm install -g @anthropic-ai/claude-code',
133
+ url: 'https://github.com/codehere/codehere#install',
134
+ },
135
+ authHint: {
136
+ detail: 'Conductor delegates to specialists. Install at least one (Claude Code, Codex, Aider, or OpenCode) and Conductor becomes available automatically.',
137
+ },
138
+ };
139
+ }
140
+ return {
141
+ id: 'conductor',
142
+ label: 'Conductor (codehere)',
143
+ state: 'ready',
144
+ installed: true,
145
+ };
146
+ },
147
+ spawn(prompt, options) {
148
+ return spawnConductor(prompt, options ?? {}, lookup);
149
+ },
150
+ };
151
+ return adapter;
152
+ }
153
+ /**
154
+ * Spawn the Conductor session. Does not fork a process — instead picks a
155
+ * specialist, spawns it, and proxies its events through with a single
156
+ * "delegated" event prepended to the buffer.
157
+ */
158
+ function spawnConductor(prompt, options, lookup) {
159
+ const sessionId = randomUUID();
160
+ const startedAt = Date.now();
161
+ const emitter = new EventEmitter();
162
+ const buffer = [];
163
+ let statusValue = 'running';
164
+ // Look up installed specialists (excluding Conductor itself to avoid recursion)
165
+ const installedAgentIds = lookup()
166
+ .filter((a) => a.id !== 'conductor' && a.isInstalled())
167
+ .map((a) => a.id);
168
+ const decision = pickSpecialist(prompt, installedAgentIds);
169
+ // If nothing's installed, emit an error event and return a non-running session.
170
+ if (!decision) {
171
+ statusValue = 'error';
172
+ const errEvent = {
173
+ type: 'error',
174
+ timestamp: Date.now(),
175
+ data: {
176
+ message: 'Conductor has no specialists to delegate to. Install at least one of: Claude Code, Codex, Aider, OpenCode.',
177
+ },
178
+ };
179
+ buffer.push(errEvent);
180
+ queueMicrotask(() => emitter.emit('event', errEvent));
181
+ return synthesizeSession({ sessionId, startedAt, prompt, statusValue, emitter, buffer });
182
+ }
183
+ // Emit the routing-decision event FIRST so the audit log records WHY this
184
+ // specialist was chosen. Layer 7 agent-provenance — every action attributable.
185
+ const routingEvent = {
186
+ type: 'codehere:conductor-routing',
187
+ timestamp: Date.now(),
188
+ data: {
189
+ conductorSessionId: sessionId,
190
+ delegatedTo: decision.agentId,
191
+ reason: decision.reason,
192
+ promptExcerpt: prompt.slice(0, 200),
193
+ },
194
+ };
195
+ buffer.push(routingEvent);
196
+ queueMicrotask(() => emitter.emit('event', routingEvent));
197
+ // Find the chosen specialist adapter and spawn against it.
198
+ const specialist = lookup().find((a) => a.id === decision.agentId);
199
+ if (!specialist) {
200
+ // Defensive: pickSpecialist returned an ID not in the registry. Shouldn't
201
+ // happen if installedAgentIds was derived from lookup(), but guard anyway.
202
+ statusValue = 'error';
203
+ const errEvent = {
204
+ type: 'error',
205
+ timestamp: Date.now(),
206
+ data: { message: `Conductor picked '${decision.agentId}' but no adapter with that ID is registered.` },
207
+ };
208
+ buffer.push(errEvent);
209
+ queueMicrotask(() => emitter.emit('event', errEvent));
210
+ return synthesizeSession({ sessionId, startedAt, prompt, statusValue, emitter, buffer });
211
+ }
212
+ // Spawn the specialist session and proxy every event through.
213
+ const specialistSession = specialist.spawn(prompt, options);
214
+ let stepsSnapshot = [];
215
+ let artifactsSnapshot;
216
+ specialistSession.subscribe((evt) => {
217
+ buffer.push(evt);
218
+ emitter.emit('event', evt);
219
+ // Mirror progress + artifacts so PM-UX layers see them on the Conductor session, too.
220
+ if (specialistSession.steps)
221
+ stepsSnapshot = specialistSession.steps;
222
+ if (specialistSession.artifacts)
223
+ artifactsSnapshot = specialistSession.artifacts;
224
+ if (evt.type === 'codehere:complete' || evt.type === 'codehere:error') {
225
+ statusValue = specialistSession.status;
226
+ }
227
+ });
228
+ return {
229
+ sessionId,
230
+ startedAt,
231
+ prompt,
232
+ agent: 'conductor',
233
+ get status() {
234
+ return statusValue;
235
+ },
236
+ get steps() {
237
+ return stepsSnapshot;
238
+ },
239
+ get artifacts() {
240
+ return artifactsSnapshot;
241
+ },
242
+ canAppendMessage: false, // v0.4-alpha doesn't support mid-session redirection
243
+ subscribe(handler) {
244
+ for (const e of buffer)
245
+ handler(e);
246
+ emitter.on('event', handler);
247
+ return () => emitter.off('event', handler);
248
+ },
249
+ kill() {
250
+ specialistSession.kill();
251
+ statusValue = 'killed';
252
+ },
253
+ };
254
+ }
255
+ /** Build a TaskSession that just replays an event buffer — used for error states. */
256
+ function synthesizeSession(args) {
257
+ const { sessionId, startedAt, prompt, statusValue, emitter, buffer } = args;
258
+ return {
259
+ sessionId,
260
+ startedAt,
261
+ prompt,
262
+ agent: 'conductor',
263
+ get status() {
264
+ return statusValue;
265
+ },
266
+ subscribe(handler) {
267
+ for (const e of buffer)
268
+ handler(e);
269
+ emitter.on('event', handler);
270
+ return () => emitter.off('event', handler);
271
+ },
272
+ kill() {
273
+ /* nothing to kill — never started */
274
+ },
275
+ };
276
+ }
277
+ //# 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.3.0-rc.3",
3
+ "version": "0.3.0-rc.5",
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",