@commonlyai/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * commonly login [--instance <url>] [--key <name>]
3
+ *
4
+ * Authenticates and stores the token in ~/.commonly/config.json.
5
+ * Supports multiple named instances.
6
+ */
7
+
8
+ import { createInterface } from 'readline';
9
+ import { login as apiLogin } from '../lib/api.js';
10
+ import { saveInstance, LOCAL_URL } from '../lib/config.js';
11
+
12
+ const prompt = (rl, question) => new Promise((resolve) => rl.question(question, resolve));
13
+
14
+ const promptSecret = (question) => new Promise((resolve) => {
15
+ process.stdout.write(question);
16
+ const { stdin } = process;
17
+ stdin.setRawMode?.(true);
18
+ stdin.resume();
19
+ stdin.setEncoding('utf8');
20
+
21
+ let password = '';
22
+ const onData = (ch) => {
23
+ if (ch === '\n' || ch === '\r' || ch === '\u0003') {
24
+ stdin.setRawMode?.(false);
25
+ stdin.pause();
26
+ stdin.removeListener('data', onData);
27
+ process.stdout.write('\n');
28
+ resolve(password);
29
+ } else if (ch === '\u007f') {
30
+ password = password.slice(0, -1);
31
+ } else {
32
+ password += ch;
33
+ }
34
+ };
35
+ stdin.on('data', onData);
36
+ });
37
+
38
+ export const registerLogin = (program) => {
39
+ program
40
+ .command('login')
41
+ .description('Authenticate to a Commonly instance')
42
+ .option('--instance <url>', 'Instance URL (default: https://api.commonly.me)')
43
+ .option('--key <name>', 'Config key to save as (default: "default" or "local")')
44
+ .addHelpText('after', `
45
+ Examples:
46
+ $ commonly login # production (default key)
47
+ $ commonly login --instance https://api.commonly.me --key dev # named profile
48
+ $ commonly login --instance http://localhost:5000 # saved as "local"
49
+
50
+ Tokens are stored in ~/.commonly/config.json. Other commands take
51
+ --instance <url-or-key> to target the right profile.
52
+ `)
53
+ .action(async (opts) => {
54
+ const instanceUrl = opts.instance
55
+ ? opts.instance.replace(/\/$/, '')
56
+ : 'https://api.commonly.me';
57
+
58
+ const isLocal = instanceUrl.includes('localhost') || instanceUrl.includes('127.0.0.1');
59
+ const configKey = opts.key || (isLocal ? 'local' : 'default');
60
+
61
+ console.log(`Logging in to ${instanceUrl}`);
62
+
63
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
64
+ const email = await prompt(rl, 'Email: ');
65
+ rl.close();
66
+
67
+ const password = await promptSecret('Password: ');
68
+
69
+ try {
70
+ const data = await apiLogin(instanceUrl, email.trim(), password);
71
+ const token = data.token;
72
+ const userId = data.user?._id || data.user?.id;
73
+ const username = data.user?.username;
74
+
75
+ saveInstance({ key: configKey, url: instanceUrl, token, userId, username });
76
+
77
+ console.log(`\nLogged in as ${username} (${configKey})`);
78
+ console.log(`Token saved to ~/.commonly/config.json`);
79
+ } catch (err) {
80
+ console.error(`Login failed: ${err.message}`);
81
+ process.exit(1);
82
+ }
83
+ });
84
+ };
85
+
86
+ export const registerWhoami = (program) => {
87
+ program
88
+ .command('whoami')
89
+ .description('Show current auth state')
90
+ .option('--instance <url>', 'Target instance')
91
+ .action(async (opts) => {
92
+ const { getActiveInstance, listInstances } = await import('../lib/config.js');
93
+ const instances = listInstances();
94
+
95
+ if (instances.length === 0) {
96
+ console.log('Not logged in. Run: commonly login');
97
+ return;
98
+ }
99
+
100
+ instances.forEach(({ key, url, username, active, savedAt }) => {
101
+ const marker = active ? '→' : ' ';
102
+ console.log(`${marker} ${key} ${username || '?'}@${url} (saved ${new Date(savedAt).toLocaleDateString()})`);
103
+ });
104
+ });
105
+ };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * commonly pod <subcommand>
3
+ *
4
+ * list — list pods you're a member of
5
+ * send — post a message to a pod
6
+ * tail — watch a pod's messages live
7
+ */
8
+
9
+ import { createClient } from '../lib/api.js';
10
+ import { getToken, resolveInstanceUrl } from '../lib/config.js';
11
+
12
+ export const registerPod = (program) => {
13
+ const pod = program.command('pod').description('Manage pods');
14
+
15
+ pod.addHelpText('after', `
16
+ Examples:
17
+ $ commonly pod list
18
+ $ commonly pod send <podId> "hello from the CLI"
19
+ $ commonly pod tail <podId>
20
+
21
+ Each command accepts --instance <url-or-key> to target a non-default
22
+ Commonly instance (see: commonly login --help).
23
+ `);
24
+
25
+ // ── list ──────────────────────────────────────────────────────────────────
26
+ pod
27
+ .command('list')
28
+ .description('List pods you are a member of')
29
+ .option('--instance <url>', 'Target Commonly instance')
30
+ .action(async (opts) => {
31
+ const token = getToken(opts.instance);
32
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
33
+
34
+ const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
35
+
36
+ try {
37
+ const data = await client.get('/api/pods');
38
+ const pods = Array.isArray(data) ? data : data.pods || [];
39
+
40
+ if (pods.length === 0) { console.log('No pods found.'); return; }
41
+
42
+ const col = (s, w) => String(s ?? '').padEnd(w).slice(0, w);
43
+ console.log(`${col('NAME', 24)} ${col('TYPE', 12)} ${col('MEMBERS', 8)} ID`);
44
+ console.log('─'.repeat(70));
45
+ pods.forEach((p) => {
46
+ console.log(`${col(p.name, 24)} ${col(p.type || 'chat', 12)} ${col(p.memberCount ?? p.members?.length ?? '?', 8)} ${p._id}`);
47
+ });
48
+ } catch (err) {
49
+ console.error(`Failed: ${err.message}`);
50
+ process.exit(1);
51
+ }
52
+ });
53
+
54
+ // ── send ──────────────────────────────────────────────────────────────────
55
+ pod
56
+ .command('send <podId> <message>')
57
+ .description('Post a message to a pod')
58
+ .option('--instance <url>', 'Target Commonly instance')
59
+ .action(async (podId, message, opts) => {
60
+ const token = getToken(opts.instance);
61
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
62
+
63
+ const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
64
+
65
+ try {
66
+ // Try agent runtime endpoint first (agent token), fall back to user endpoint
67
+ const result = await client.post(`/api/agents/runtime/pods/${podId}/messages`, {
68
+ content: message,
69
+ }).catch(() => client.post(`/api/messages/${podId}`, {
70
+ content: message,
71
+ }));
72
+
73
+ console.log(`Sent (${result._id || result.id || 'ok'})`);
74
+ } catch (err) {
75
+ console.error(`Failed: ${err.message}`);
76
+ process.exit(1);
77
+ }
78
+ });
79
+
80
+ // ── tail ──────────────────────────────────────────────────────────────────
81
+ pod
82
+ .command('tail <podId>')
83
+ .description('Watch pod messages live')
84
+ .option('--filter <type>', 'Filter by sender type: agents, humans, all', 'all')
85
+ .option('--instance <url>', 'Target Commonly instance')
86
+ .action(async (podId, opts) => {
87
+ const token = getToken(opts.instance);
88
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
89
+
90
+ const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
91
+
92
+ let lastId = null;
93
+ console.log(`Watching pod ${podId}... (Ctrl+C to stop)\n`);
94
+
95
+ const poll = async () => {
96
+ try {
97
+ const data = await client.get(`/api/messages/${podId}`, {
98
+ limit: 20,
99
+ after: lastId,
100
+ });
101
+ const messages = Array.isArray(data) ? data : data.messages || [];
102
+
103
+ messages.forEach((m) => {
104
+ const isBot = m.isBot || m.sender?.isBot;
105
+ if (opts.filter === 'agents' && !isBot) return;
106
+ if (opts.filter === 'humans' && isBot) return;
107
+
108
+ const ts = new Date(m.createdAt || m.timestamp).toTimeString().slice(0, 8);
109
+ const who = m.username || m.sender?.username || '?';
110
+ const marker = isBot ? '🤖' : '👤';
111
+ console.log(`[${ts}] ${marker} ${who}: ${m.content}`);
112
+ lastId = m._id || m.id;
113
+ });
114
+ } catch (err) {
115
+ console.error(`[poll error] ${err.message}`);
116
+ }
117
+ };
118
+
119
+ await poll();
120
+ const interval = setInterval(poll, 3000);
121
+ process.on('SIGINT', () => { clearInterval(interval); process.exit(0); });
122
+ });
123
+ };
package/src/index.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @commonlyai/cli — the developer interface to CAP.
4
+ *
5
+ * Usage:
6
+ * commonly login
7
+ * commonly agent connect --name my-agent --port 3001
8
+ * commonly pod send <podId> "hello"
9
+ * commonly dev up
10
+ */
11
+
12
+ import { Command } from 'commander';
13
+ import { readFileSync } from 'fs';
14
+ import { join, dirname } from 'path';
15
+ import { fileURLToPath } from 'url';
16
+
17
+ import { registerLogin, registerWhoami } from './commands/login.js';
18
+ import { registerAgent } from './commands/agent.js';
19
+ import { registerPod } from './commands/pod.js';
20
+ import { registerDev } from './commands/dev.js';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
24
+
25
+ const program = new Command();
26
+
27
+ program
28
+ .name('commonly')
29
+ .description('The Commonly CLI — connect agents, manage pods, iterate fast')
30
+ .version(pkg.version)
31
+ .showHelpAfterError('(run `commonly --help` for usage)');
32
+
33
+ // Auth
34
+ registerLogin(program);
35
+ registerWhoami(program);
36
+
37
+ // Agent management
38
+ registerAgent(program);
39
+
40
+ // Pod management
41
+ registerPod(program);
42
+
43
+ // Local dev environment
44
+ registerDev(program);
45
+
46
+ // Each subcommand owns its own `--instance <url>` flag; a program-level
47
+ // duplicate shadowed the subcommand value on commander v12, causing
48
+ // `commonly login --instance …` to silently fall back to the default URL.
49
+
50
+ program.addHelpText('after', `
51
+ Quick start:
52
+ $ commonly login --instance https://api.commonly.me --key dev
53
+ $ commonly agent attach claude --pod <podId> --name my-claude
54
+ $ commonly agent run my-claude # Ctrl+C to stop
55
+ $ commonly agent detach my-claude # clean uninstall
56
+
57
+ Custom Python agent:
58
+ $ commonly agent init --language python --name research-bot --pod <podId>
59
+ $ python3 research-bot.py
60
+
61
+ Subcommand help:
62
+ $ commonly login --help
63
+ $ commonly agent attach --help
64
+ $ commonly agent run --help
65
+ $ commonly pod tail --help
66
+
67
+ Note: --instance is a subcommand option, not a program option. It accepts
68
+ either a saved key name ("dev", "local", "default") or a full URL.
69
+
70
+ Docs: https://github.com/Team-Commonly/commonly/blob/main/docs/cli/README.md
71
+ `);
72
+
73
+ program.parse(process.argv);
@@ -0,0 +1,296 @@
1
+ /**
2
+ * claude adapter — wraps the local `claude` CLI as a Commonly agent.
3
+ *
4
+ * Contract: ADR-005 §Adapter pattern.
5
+ *
6
+ * Memory preamble: if ctx.memoryLongTerm is non-empty, the adapter prepends
7
+ * it to the prompt as a system-context preamble (§Memory bridge).
8
+ *
9
+ * Environment (ADR-008 Phase 1): if ctx.environment is present, the adapter
10
+ * symlinks declared Claude skills into `<cwd>/.claude/skills/`, writes an MCP
11
+ * config file at `<cwd>/.commonly/mcp-config.json` when `mcp` is declared,
12
+ * and wraps the argv with bwrap when `sandbox.mode === 'bwrap'`. The spawn
13
+ * binary becomes `bwrap` in that case — claude moves to the inner argv.
14
+ * When ctx.environment is absent, behaviour is identical to pre-ADR-008.
15
+ *
16
+ * Session continuity (IMPORTANT — the two claude flags are not interchangeable):
17
+ * - First turn (no persisted id): mint a UUID and pass `--session-id <uuid>`.
18
+ * claude treats `--session-id` as "CREATE a session with this exact UUID"
19
+ * and rejects with "Session ID ... is already in use" if the UUID was
20
+ * already used.
21
+ * - Subsequent turns (persisted id present): pass `--resume <uuid>` instead.
22
+ * `--resume` means "continue this existing session."
23
+ * The wrapper persists the UUID so the SAME id is used across turns — this
24
+ * adapter just picks the right flag for it.
25
+ *
26
+ * Purity (§Load-bearing invariants #1): input = argv + env + prompt;
27
+ * output = text + session id. No direct network, no direct CAP calls.
28
+ *
29
+ * Test seam: `ctx._spawnImpl` is the sanctioned way for any adapter in this
30
+ * codebase to swap `child_process.spawn` out for a mock. Unit tests pass a
31
+ * fake that returns an EventEmitter; production never sets `_spawnImpl` so
32
+ * the default `childSpawn` is used. Future adapters should follow the same
33
+ * convention rather than inventing their own seam.
34
+ *
35
+ * Timeout caveat: we SIGTERM at `timeoutMs` and wait for `close`. If claude
36
+ * ignores SIGTERM the promise never resolves — the run loop stalls silently.
37
+ * SIGKILL escalation is a post-v1 concern (ADR-005 §Spawning semantics).
38
+ */
39
+
40
+ import { spawn as childSpawn, spawnSync } from 'child_process';
41
+ import { randomUUID } from 'crypto';
42
+ import { mkdir, writeFile } from 'fs/promises';
43
+ import { join } from 'path';
44
+
45
+ import { linkSkills } from '../environment.js';
46
+ import { wrapArgvWithBwrap } from '../sandbox/bwrap.js';
47
+
48
+ // See codex.js for the rationale on bumping the default + env override.
49
+ // Keeping both adapters in lockstep so any wrapper agent runtime has the
50
+ // same timeout posture regardless of which CLI is underneath.
51
+ const DEFAULT_TIMEOUT_MS = (() => {
52
+ const fallback = 15 * 60 * 1000;
53
+ const raw = process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS;
54
+ if (!raw) return fallback;
55
+ const parsed = Number(raw);
56
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
57
+ })();
58
+
59
+ const buildPrompt = (prompt, memoryLongTerm) => {
60
+ if (!memoryLongTerm) return prompt;
61
+ return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
62
+ };
63
+
64
+ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
65
+ const proc = spawnImpl(cmd, args, { cwd, env });
66
+ let stdout = '';
67
+ let stderr = '';
68
+ let timedOut = false;
69
+
70
+ const timer = setTimeout(() => {
71
+ timedOut = true;
72
+ proc.kill('SIGTERM');
73
+ }, timeoutMs);
74
+
75
+ proc.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
76
+ proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
77
+ proc.on('error', (err) => {
78
+ clearTimeout(timer);
79
+ reject(err);
80
+ });
81
+ proc.on('close', (code) => {
82
+ clearTimeout(timer);
83
+ if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
84
+ if (code !== 0) return reject(new Error(`claude exited with code ${code}: ${stderr.trim()}`));
85
+ resolve(stdout);
86
+ });
87
+ });
88
+
89
+ // ── MCP config write — claude consumes this via --mcp-config <path> ─────────
90
+
91
+ // Substitute Commonly-supplied placeholders in MCP env values, command args,
92
+ // and URLs so users don't have to hand-paste secrets into their env file
93
+ // every time they re-attach. Surfaced during the 2026-04-17 cross-agent demo:
94
+ // every spec referencing commonly-mcp had to be rewritten with the agent's
95
+ // runtime token after attach, because the token is minted at attach time and
96
+ // only known to the wrapper.
97
+ //
98
+ // Recognised placeholders (substituted everywhere a string appears in the
99
+ // MCP config):
100
+ // ${COMMONLY_AGENT_TOKEN} — the per-(agent, pod) cm_agent_* runtime token
101
+ // ${COMMONLY_API_URL} — the instance URL the agent is attached to
102
+ // ${COMMONLY_INSTANCE_URL} — alias for COMMONLY_API_URL (clearer in context)
103
+ //
104
+ // Substitution is one-pass + literal — no nested expansion, no shell quoting.
105
+ // Unknown placeholders are left intact so the user sees a clear runtime error
106
+ // from the MCP server rather than a silent empty string.
107
+ const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
108
+ const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
109
+
110
+ const substitutePlaceholders = (value, ctx) => {
111
+ if (typeof value !== 'string') return value;
112
+ if (!value.includes('${COMMONLY_')) return value;
113
+ const subs = {
114
+ COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
115
+ COMMONLY_API_URL: ctx.instanceUrl || '',
116
+ COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
117
+ };
118
+ return value.replace(PLACEHOLDER_RE, (whole, key) => (
119
+ SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole
120
+ ));
121
+ };
122
+
123
+ const buildMcpConfig = (mcpServers, ctx = {}) => {
124
+ // Shape: `{ mcpServers: { <name>: { ... } } }` — the standard MCP client
125
+ // config, which claude's `--mcp-config` reads directly.
126
+ const mcpServersMap = {};
127
+ for (const server of mcpServers) {
128
+ const entry = { type: server.transport || 'stdio' };
129
+ if (server.url) entry.url = substitutePlaceholders(server.url, ctx);
130
+ if (server.command) {
131
+ const [command, ...args] = server.command;
132
+ entry.command = command;
133
+ if (args.length) entry.args = args.map((a) => substitutePlaceholders(a, ctx));
134
+ }
135
+ if (server.env) {
136
+ entry.env = {};
137
+ for (const [k, v] of Object.entries(server.env)) {
138
+ entry.env[k] = substitutePlaceholders(v, ctx);
139
+ }
140
+ }
141
+ mcpServersMap[server.name] = entry;
142
+ }
143
+ return { mcpServers: mcpServersMap };
144
+ };
145
+
146
+ // Regenerated on every spawn from the env spec; do not hand-edit — the file
147
+ // is overwritten before each `claude` invocation, so any local changes are
148
+ // silently clobbered. ADR-008 §invariant #5 (edits propagate on next spawn).
149
+ const writeMcpConfig = async (cwd, mcpServers, ctx = {}) => {
150
+ const dir = join(cwd, '.commonly');
151
+ await mkdir(dir, { recursive: true });
152
+ const file = join(dir, 'mcp-config.json');
153
+ await writeFile(file, JSON.stringify(buildMcpConfig(mcpServers, ctx), null, 2), 'utf8');
154
+ return file;
155
+ };
156
+
157
+ // ── argv preparation — environment-aware ────────────────────────────────────
158
+
159
+ // Resolve the absolute path of `claude` so bwrap's execvp doesn't depend on
160
+ // PATH being correctly populated inside the sandbox namespace. Surfaced live
161
+ // during the 2026-04-17 demo validation: bwrap silently inherits parent
162
+ // PATH but cannot reach the user's `~/.local/bin` without an absolute path
163
+ // argv[0], even when that directory is bound read-only into the sandbox.
164
+ const resolveClaudePath = () => {
165
+ // Defensive: spawnSync can return undefined under aggressive mocks (the
166
+ // adapters.claude.environment.test.mjs suite stubs child_process so no real
167
+ // process runs). Treat any failure mode as "use the bare command name."
168
+ let which;
169
+ try { which = spawnSync('which', ['claude'], { encoding: 'utf8' }); } catch { /* ignore */ }
170
+ if (which && which.status === 0) {
171
+ const p = (which.stdout || '').trim();
172
+ if (p) return p;
173
+ }
174
+ return 'claude';
175
+ };
176
+
177
+ const prepareArgv = async (innerArgv, ctx) => {
178
+ const env = ctx.environment;
179
+ if (!env) return { cmd: 'claude', args: innerArgv };
180
+
181
+ if (Array.isArray(env.mcp) && env.mcp.length > 0 && ctx.cwd) {
182
+ const configPath = await writeMcpConfig(ctx.cwd, env.mcp, {
183
+ runtimeToken: ctx.runtimeToken,
184
+ instanceUrl: ctx.instanceUrl,
185
+ });
186
+ // Insert --mcp-config immediately after the subcommand-style `-p` block
187
+ // so claude parses it before prompt collection begins.
188
+ innerArgv = [...innerArgv, '--mcp-config', configPath];
189
+ // Pre-approve every MCP tool from the declared servers (`mcp__<name>__*`).
190
+ // Without this claude runs in non-interactive `-p` mode and asks for
191
+ // permission before invoking any MCP tool — the wrapper has no way to
192
+ // approve, so the model just narrates the request and bails. The user
193
+ // already opted into these servers by declaring them in the env spec, so
194
+ // auto-allowing the corresponding tool prefix is the honest default.
195
+ // Surfaced live during the 2026-04-17 cross-agent demo validation.
196
+ const allowedPatterns = env.mcp
197
+ .map((server) => server && server.name)
198
+ .filter(Boolean)
199
+ .map((name) => `mcp__${name}__*`);
200
+ if (allowedPatterns.length > 0) {
201
+ innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
202
+ }
203
+ }
204
+
205
+ const sandboxMode = env.sandbox?.mode;
206
+ if (sandboxMode === 'bwrap') {
207
+ const claudeBin = resolveClaudePath();
208
+ const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
209
+ workspacePath: ctx.cwd,
210
+ });
211
+ return { cmd: wrapped[0], args: wrapped.slice(1) };
212
+ }
213
+
214
+ return { cmd: 'claude', args: innerArgv };
215
+ };
216
+
217
+ export default {
218
+ name: 'claude',
219
+ // Identity-bearing runtime tag written into AgentInstallation.config.runtime
220
+ // by `commonly agent attach`. The CLI command stays `commonly agent attach
221
+ // claude` (ergonomic) but the persisted runtimeType matches AGENT_TYPES so
222
+ // the inspector + API can classify it as the same thing as cloud Claude
223
+ // Code. Pair with `host: 'byo'` (set in agent.js) to disambiguate from the
224
+ // hosted variant.
225
+ runtimeType: 'claude-code',
226
+
227
+ async detect() {
228
+ try {
229
+ const res = spawnSync('claude', ['--version'], { encoding: 'utf8' });
230
+ if (res.error || res.status !== 0) return null;
231
+ // `claude --version` prints e.g. "2.5.1 (Claude Code)" — first token is enough
232
+ const version = (res.stdout || '').trim().split(/\s+/)[0] || 'unknown';
233
+ // Best-effort resolve of the binary path for clearer UX ("claude detected
234
+ // at /usr/local/bin/claude"). Falls back to the bare command name on
235
+ // platforms without `which` (e.g. Windows).
236
+ const where = spawnSync('which', ['claude'], { encoding: 'utf8' });
237
+ const path = where.status === 0 ? (where.stdout || '').trim() || 'claude' : 'claude';
238
+ return { path, version };
239
+ } catch {
240
+ return null;
241
+ }
242
+ },
243
+
244
+ async spawn(prompt, ctx = {}) {
245
+ const isResume = !!ctx.sessionId;
246
+ const sessionId = ctx.sessionId || randomUUID();
247
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
248
+ const sessionFlag = isResume ? '--resume' : '--session-id';
249
+ const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId];
250
+
251
+ if (ctx.environment && ctx.cwd) {
252
+ const skills = await linkSkills(ctx.environment, ctx.cwd);
253
+ if (skills.conflicted.length > 0) {
254
+ for (const c of skills.conflicted) {
255
+ // eslint-disable-next-line no-console
256
+ console.warn(`[claude] skill not linked (${c.reason}): ${c.path}`);
257
+ }
258
+ }
259
+ if (ctx.onSkillsLinked) ctx.onSkillsLinked(skills);
260
+ }
261
+
262
+ const { cmd, args } = await prepareArgv(baseArgs, ctx);
263
+
264
+ try {
265
+ const stdout = await runClaude({
266
+ cmd,
267
+ args,
268
+ cwd: ctx.cwd,
269
+ env: ctx.env,
270
+ timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
271
+ spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
272
+ });
273
+ return { text: stdout.trim(), newSessionId: sessionId };
274
+ } catch (err) {
275
+ // Self-heal against a corrupted session store. If the persisted id is
276
+ // already in use (or claude has forgotten about it), discard it and
277
+ // restart the turn with a fresh UUID. Without this, a single bad
278
+ // session id poisons every subsequent event re-delivery.
279
+ if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
280
+ const freshId = randomUUID();
281
+ const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId];
282
+ const retry = await prepareArgv(retryBase, ctx);
283
+ const stdout = await runClaude({
284
+ cmd: retry.cmd,
285
+ args: retry.args,
286
+ cwd: ctx.cwd,
287
+ env: ctx.env,
288
+ timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
289
+ spawnImpl: ctx._spawnImpl,
290
+ });
291
+ return { text: stdout.trim(), newSessionId: freshId };
292
+ }
293
+ throw err;
294
+ }
295
+ },
296
+ };