@commonlyai/cli 0.1.44 → 0.1.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -111,7 +111,7 @@ export const deleteAgentToken = (name) => {
111
111
  // podId) or is a local fact (which CLI binary to wrap). Returns a record ready
112
112
  // for saveAgentToken, or null when COMMONLY_AGENT_TOKEN isn't set (caller
113
113
  // falls back to the attach hint).
114
- export const BOOTSTRAP_ADAPTER_DETECT_ORDER = ['claude', 'codex'];
114
+ export const BOOTSTRAP_ADAPTER_DETECT_ORDER = ['claude', 'codex', 'pi'];
115
115
 
116
116
  export const bootstrapAgentRecordFromEnv = async ({
117
117
  name,
@@ -254,7 +254,7 @@ const PRIVATE_RESPONSE_EVENT_TYPES = new Set(['agent.ask', 'agent.ask.response']
254
254
  // agent posted through the operator's CLI profile because it had no
255
255
  // commonly_* tools of its own). `stub` does not. Returning null means "no
256
256
  // default" — the wrapper proceeds with environment=null exactly like before.
257
- const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex']);
257
+ const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi']);
258
258
  const CODEX_PERMISSION_PROFILE_MIN_VERSION = [0, 138, 0];
259
259
 
260
260
  const versionAtLeast = (version, minimum) => {
@@ -10,11 +10,13 @@
10
10
  import stub from './stub.js';
11
11
  import claude from './claude.js';
12
12
  import codex from './codex.js';
13
+ import pi from './pi.js';
13
14
 
14
15
  const ADAPTERS = {
15
16
  [stub.name]: stub,
16
17
  [claude.name]: claude,
17
18
  [codex.name]: codex,
19
+ [pi.name]: pi,
18
20
  };
19
21
 
20
22
  export const listAdapterNames = () => Object.keys(ADAPTERS);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * pi extension: Commonly's tools for a pi seat, over MCP stdio.
3
+ *
4
+ * Loaded by adapters/pi.js with `-e`. Reads COMMONLY_PI_MCP — a JSON list of
5
+ * `{ name, command: [...], env: {...} }` — starts each server on stdio, asks
6
+ * it for its tools, and registers every one with pi under its own name, so a
7
+ * pi seat calls `commonly_post_message` exactly as a claude or codex seat
8
+ * does. Tool calls are forwarded as MCP `tools/call`; results come back as
9
+ * text. The client lives in pi-mcp-client.mjs (jest-tested); this file only
10
+ * binds it to pi's `registerTool`. `typebox` resolves through pi's extension
11
+ * loader, which aliases its bundled copy — it is not a CLI dependency.
12
+ */
13
+
14
+ import { Type } from 'typebox';
15
+ import { connectMcp, readServers, toPiResult } from './pi-mcp-client.mjs';
16
+
17
+ export default async function commonlyMcpBridge(pi) {
18
+ const servers = readServers(process.env.COMMONLY_PI_MCP);
19
+ const clients = [];
20
+ for (const server of servers) {
21
+ const client = connectMcp(server);
22
+ clients.push(client);
23
+ try {
24
+ await client.initialize();
25
+ const tools = await client.listTools();
26
+ for (const tool of tools) {
27
+ pi.registerTool({
28
+ name: tool.name,
29
+ label: tool.name,
30
+ description: tool.description || tool.name,
31
+ // The server's JSON schema, passed through: pi validates against it as-is.
32
+ parameters: Type.Unsafe(tool.inputSchema || { type: 'object', properties: {} }),
33
+ async execute(_toolCallId, params) {
34
+ return toPiResult(await client.callTool(tool.name, params));
35
+ },
36
+ });
37
+ }
38
+ } catch (error) {
39
+ process.stderr.write(`[commonly-pi-bridge] ${server.name}: ${error.message}\n`);
40
+ }
41
+ }
42
+ const shutdown = () => { for (const client of clients) client.close(); };
43
+ process.on('exit', shutdown);
44
+ if (typeof pi.on === 'function') pi.on('session_shutdown', shutdown);
45
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A minimal MCP stdio client for the pi bridge (pi-commonly-mcp.mjs).
3
+ *
4
+ * The stdio transport is newline-delimited JSON-RPC; a client for four
5
+ * methods (initialize, tools/list, tools/call, the initialized notification)
6
+ * is smaller than a dependency, and keeping it in a file with no pi imports
7
+ * means the CLI's own jest can test it — `typebox` only resolves inside pi's
8
+ * extension loader, so the extension file stays thin and untested by jest.
9
+ */
10
+
11
+ import { spawn } from 'node:child_process';
12
+
13
+ const PROTOCOL_VERSION = '2024-11-05';
14
+ const MAX_TEXT = 50 * 1024;
15
+
16
+ const truncate = (text) => (text.length <= MAX_TEXT
17
+ ? text
18
+ : `${text.slice(0, MAX_TEXT)}\n… [truncated ${text.length - MAX_TEXT} bytes]`);
19
+
20
+ /** A minimal MCP stdio client: initialize, tools/list, tools/call. */
21
+ export const connectMcp = ({ name, command, env }, { spawnImpl = spawn, timeoutMs = 60_000 } = {}) => {
22
+ const [cmd, ...args] = command;
23
+ const proc = spawnImpl(cmd, args, { env: { ...process.env, ...(env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
24
+ const pending = new Map();
25
+ let nextId = 1;
26
+ let buffer = '';
27
+ proc.stdout.on('data', (chunk) => {
28
+ buffer += chunk.toString();
29
+ let nl;
30
+ while ((nl = buffer.indexOf('\n')) !== -1) {
31
+ const line = buffer.slice(0, nl).trim();
32
+ buffer = buffer.slice(nl + 1);
33
+ if (!line) continue;
34
+ let msg;
35
+ try { msg = JSON.parse(line); } catch { continue; }
36
+ const waiter = msg && msg.id !== undefined ? pending.get(msg.id) : null;
37
+ if (!waiter) continue;
38
+ pending.delete(msg.id);
39
+ clearTimeout(waiter.timer);
40
+ if (msg.error) waiter.reject(new Error(`${name}: ${msg.error.message || JSON.stringify(msg.error)}`));
41
+ else waiter.resolve(msg.result);
42
+ }
43
+ });
44
+ proc.on('exit', (code) => {
45
+ for (const [id, waiter] of pending) {
46
+ pending.delete(id);
47
+ clearTimeout(waiter.timer);
48
+ waiter.reject(new Error(`${name}: MCP server exited (${code}) before answering`));
49
+ }
50
+ });
51
+ const send = (obj) => proc.stdin.write(`${JSON.stringify(obj)}\n`);
52
+ const request = (method, params) => new Promise((resolve, reject) => {
53
+ const id = nextId++;
54
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${name}: ${method} timed out after ${timeoutMs}ms`)); }, timeoutMs);
55
+ pending.set(id, { resolve, reject, timer });
56
+ send({ jsonrpc: '2.0', id, method, params: params || {} });
57
+ });
58
+ const notify = (method, params) => send({ jsonrpc: '2.0', method, params: params || {} });
59
+ const initialize = async () => {
60
+ const result = await request('initialize', {
61
+ protocolVersion: PROTOCOL_VERSION,
62
+ capabilities: {},
63
+ clientInfo: { name: 'commonly-pi-bridge', version: '1.0.0' },
64
+ });
65
+ notify('notifications/initialized');
66
+ return result;
67
+ };
68
+ const listTools = async () => (await request('tools/list')).tools || [];
69
+ const callTool = (toolName, args) => request('tools/call', { name: toolName, arguments: args || {} });
70
+ const close = () => { try { proc.kill('SIGTERM'); } catch { /* already gone */ } };
71
+ return { initialize, listTools, callTool, close, proc };
72
+ };
73
+
74
+ /** MCP `{ content, isError }` → pi tool result. Non-text parts are named, not dropped silently. */
75
+ export const toPiResult = (result) => {
76
+ const parts = (result?.content || []).map((c) => (c?.type === 'text' ? String(c.text ?? '') : `[${c?.type || 'content'} omitted]`));
77
+ const text = truncate(parts.join('\n'));
78
+ return { content: [{ type: 'text', text: result?.isError ? `error: ${text}` : text }], details: { isError: !!result?.isError } };
79
+ };
80
+
81
+ export const readServers = (raw) => {
82
+ if (!raw) return [];
83
+ try { return JSON.parse(raw).filter((s) => s?.name && Array.isArray(s.command) && s.command.length); } catch { return []; }
84
+ };
85
+
@@ -0,0 +1,241 @@
1
+ /**
2
+ * pi adapter — ADR-005 adapter contract for the pi coding agent
3
+ * (https://pi.dev, `@earendil-works/pi-coding-agent`), the harness that lets
4
+ * a wrapper seat run on any OpenAI-compatible model: DeepSeek through
5
+ * LiteLLM today, anything LiteLLM routes tomorrow.
6
+ *
7
+ * Why a third adapter (2026-09-18): the Luna code-writer seats ran codex on
8
+ * ChatGPT quota, and when that ran out the fleet stalled. codex 0.153 cannot
9
+ * drive DeepSeek — it sends a `namespace`-type tool DeepSeek's API rejects
10
+ * even with every feature flag off — while pi headless on LiteLLM's
11
+ * `deepseek-v4-flash` wrote a file with its `write` tool, ran it with `bash`
12
+ * and resumed its session on the next turn (proof on this laptop, 03:57Z).
13
+ * pi was already the hosted turn engine (ADR-021); this is pi as a wrapper.
14
+ *
15
+ * How pi is driven:
16
+ * pi -p --mode json --provider <p> --model <m> --thinking <t>
17
+ * --session-dir <seat dir> (--session-id <uuid> | --session <uuid>)
18
+ * -e pi-commonly-mcp.mjs "<prompt>"
19
+ *
20
+ * - `--session-id` creates the session on the first turn; `--session`
21
+ * resumes it (`--session-id` cannot be combined with `--continue`).
22
+ * - stdout is NDJSON; the reply is the last assistant `message_end`.
23
+ * - Provider config is a per-seat models.json under
24
+ * `~/.commonly/pi-homes/<hash>/agent`, pointed at by
25
+ * PI_CODING_AGENT_DIR — never the operator's ~/.pi. The API key is an
26
+ * env reference (`$COMMONLY_LITELLM_KEY`), so it never lands on disk.
27
+ * - Commonly's tools reach pi through pi-commonly-mcp.mjs, an extension
28
+ * that speaks MCP over stdio to every server in `environment.mcp` and
29
+ * registers each tool. The token rides through the child env, never argv
30
+ * (same rule as codex.js).
31
+ *
32
+ * Contract (see stub.js): detect() and spawn(prompt, ctx) → { text, newSessionId }.
33
+ */
34
+
35
+ import { spawn as childSpawn, spawnSync } from 'child_process';
36
+ import { createHash, randomUUID } from 'crypto';
37
+ import { mkdir, writeFile } from 'fs/promises';
38
+ import { homedir } from 'os';
39
+ import { dirname, join } from 'path';
40
+ import { fileURLToPath } from 'url';
41
+ import { buildMemoryPreamble } from '../memory-bridge.js';
42
+
43
+ const DEFAULT_TIMEOUT_MS = (() => {
44
+ const fallback = 15 * 60 * 1000;
45
+ const raw = process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS;
46
+ if (!raw) return fallback;
47
+ const parsed = Number(raw);
48
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
49
+ })();
50
+
51
+ // The default provider is Commonly's own LiteLLM, reachable from a laptop
52
+ // seat at the public ingress and from a cluster seat at the service.
53
+ export const DEFAULT_PROVIDER = Object.freeze({
54
+ name: 'litellm',
55
+ baseUrl: 'https://litellm.commonly.me/v1',
56
+ api: 'openai-completions',
57
+ apiKeyEnv: 'COMMONLY_LITELLM_KEY',
58
+ });
59
+ export const DEFAULT_MODEL = 'deepseek-v4-flash';
60
+
61
+ // ADR-008 `effort` → pi `--thinking`. pi's ladder is off/minimal/low/medium/high/xhigh/max.
62
+ const THINKING = { none: 'off', off: 'off', minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' };
63
+ export const thinkingFor = (effort) => (effort ? THINKING[String(effort).toLowerCase()] || null : null);
64
+
65
+ const BRIDGE_PATH = join(dirname(fileURLToPath(import.meta.url)), 'pi-commonly-mcp.mjs');
66
+
67
+ // Same substitution contract as claude.js / codex.js: ${COMMONLY_*}
68
+ // placeholders in the declared MCP env are the wrapper's per-(agent, pod)
69
+ // runtime values, filled at spawn time.
70
+ const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
71
+ const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
72
+ const substitutePlaceholders = (value, ctx) => {
73
+ if (typeof value !== 'string' || !value.includes('${COMMONLY_')) return value;
74
+ const subs = {
75
+ COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
76
+ COMMONLY_API_URL: ctx.instanceUrl || '',
77
+ COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
78
+ };
79
+ return value.replace(PLACEHOLDER_RE, (whole, key) => (SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole));
80
+ };
81
+
82
+ /** stdio MCP servers from the environment spec, placeholders filled; url-only entries are skipped. */
83
+ export const resolveMcpServers = (mcpServers, ctx = {}) => (mcpServers || [])
84
+ .filter((server) => server?.name && Array.isArray(server.command) && server.command.length)
85
+ .map((server) => ({
86
+ name: server.name,
87
+ command: server.command.map((a) => substitutePlaceholders(a, ctx)),
88
+ env: Object.fromEntries(Object.entries(server.env || {}).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
89
+ }));
90
+
91
+ /** The provider block for models.json: the env spec's `provider` over the LiteLLM default. */
92
+ export const resolveProvider = (environment = {}) => {
93
+ const spec = environment?.provider || {};
94
+ return {
95
+ name: spec.name || DEFAULT_PROVIDER.name,
96
+ baseUrl: spec.baseUrl || DEFAULT_PROVIDER.baseUrl,
97
+ api: spec.api || DEFAULT_PROVIDER.api,
98
+ apiKeyEnv: spec.apiKeyEnv || DEFAULT_PROVIDER.apiKeyEnv,
99
+ };
100
+ };
101
+
102
+ /** models.json content for one seat: one provider, one model, key by env reference. */
103
+ export const buildModelsJson = (provider, model) => ({
104
+ providers: {
105
+ [provider.name]: {
106
+ name: provider.name,
107
+ baseUrl: provider.baseUrl,
108
+ apiKey: `$${provider.apiKeyEnv}`,
109
+ api: provider.api,
110
+ models: [{
111
+ id: model,
112
+ name: model,
113
+ reasoning: true,
114
+ input: ['text'],
115
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
116
+ contextWindow: 128000,
117
+ maxTokens: 32000,
118
+ }],
119
+ },
120
+ },
121
+ });
122
+
123
+ /** Per-seat pi home: `~/.commonly/pi-homes/<hash(agent)>`. Never the operator's ~/.pi. */
124
+ export const seatHome = (ctx) => {
125
+ const identity = ctx.agentName || ctx.cwd || 'anonymous';
126
+ const hash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
127
+ return ctx._piHome || join(homedir(), '.commonly', 'pi-homes', hash);
128
+ };
129
+
130
+ export const buildArgs = ({
131
+ prompt, provider, model, thinking, sessionId, isResume, sessionDir, bridge,
132
+ }) => [
133
+ '-p',
134
+ '--mode', 'json',
135
+ '--no-extensions',
136
+ '--no-skills',
137
+ '--no-prompt-templates',
138
+ '--no-themes',
139
+ '--provider', provider,
140
+ '--model', model,
141
+ ...(thinking ? ['--thinking', thinking] : []),
142
+ '--session-dir', sessionDir,
143
+ ...(isResume ? ['--session', sessionId] : ['--session-id', sessionId]),
144
+ ...(bridge ? ['-e', bridge] : []),
145
+ prompt,
146
+ ];
147
+
148
+ /** The reply is the last assistant `message_end`'s text parts; tool calls are not text. */
149
+ export const extractReply = (stdout) => {
150
+ let text = '';
151
+ let sawAssistant = false;
152
+ const errors = [];
153
+ for (const line of String(stdout).split('\n')) {
154
+ if (!line.trim()) continue;
155
+ let event;
156
+ try { event = JSON.parse(line); } catch { continue; }
157
+ if (event?.type === 'message_end' && event.message?.role === 'assistant') {
158
+ const parts = (event.message.content || []).filter((c) => c?.type === 'text').map((c) => c.text || '');
159
+ if (parts.length) { text = parts.join('\n').trim(); sawAssistant = true; }
160
+ }
161
+ if (event?.type === 'error') errors.push(String(event.message || event.error || 'error'));
162
+ }
163
+ return { text, sawAssistant, errors };
164
+ };
165
+
166
+ const runPi = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
167
+ let stdout = '';
168
+ let stderr = '';
169
+ let timedOut = false;
170
+ const proc = spawnImpl('pi', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
171
+ const timer = setTimeout(() => { timedOut = true; proc.kill('SIGTERM'); }, timeoutMs);
172
+ proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
173
+ proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
174
+ proc.on('error', (err) => { clearTimeout(timer); reject(err); });
175
+ proc.on('close', (code) => {
176
+ clearTimeout(timer);
177
+ if (timedOut) return reject(new Error(`pi timed out after ${timeoutMs}ms`));
178
+ const reply = extractReply(stdout);
179
+ if (code !== 0 && !reply.sawAssistant) {
180
+ const tail = (reply.errors.join(' | ') || stderr).trim().slice(-600);
181
+ return reject(new Error(`pi exited ${code}: ${tail}`));
182
+ }
183
+ return resolve(reply);
184
+ });
185
+ });
186
+
187
+ export default {
188
+ name: 'pi',
189
+
190
+ async detect() {
191
+ const res = spawnSync('pi', ['--version'], { encoding: 'utf8' });
192
+ if (res.error || res.status !== 0) return null;
193
+ const version = String(res.stdout || '').trim().split('\n').pop() || 'unknown';
194
+ const which = spawnSync('which', ['pi'], { encoding: 'utf8' });
195
+ return { path: String(which.stdout || 'pi').trim() || 'pi', version };
196
+ },
197
+
198
+ async spawn(prompt, ctx = {}) {
199
+ const isResume = !!ctx.sessionId;
200
+ const sessionId = ctx.sessionId || randomUUID();
201
+ const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: !isResume });
202
+ const provider = resolveProvider(ctx.environment);
203
+ const model = ctx.environment?.model || DEFAULT_MODEL;
204
+ const thinking = thinkingFor(ctx.environment?.effort);
205
+ const baseEnv = ctx.env || process.env;
206
+ if (!baseEnv[provider.apiKeyEnv]) {
207
+ throw new Error(`pi adapter: ${provider.apiKeyEnv} is not set — the seat's environment must carry the provider key (LiteLLM virtual key)`);
208
+ }
209
+
210
+ // Per-seat pi home: models.json holds the provider by env reference.
211
+ const home = seatHome(ctx);
212
+ const agentDir = join(home, 'agent');
213
+ const sessionDir = join(home, 'sessions');
214
+ await mkdir(agentDir, { recursive: true, mode: 0o700 });
215
+ await mkdir(sessionDir, { recursive: true, mode: 0o700 });
216
+ await writeFile(join(agentDir, 'models.json'), `${JSON.stringify(buildModelsJson(provider, model), null, 2)}\n`, { mode: 0o600 });
217
+
218
+ const servers = resolveMcpServers(ctx.environment?.mcp, ctx);
219
+ const childEnv = {
220
+ ...baseEnv,
221
+ PI_CODING_AGENT_DIR: agentDir,
222
+ PI_SKIP_VERSION_CHECK: '1',
223
+ ...(servers.length ? { COMMONLY_PI_MCP: JSON.stringify(servers) } : {}),
224
+ };
225
+ const args = buildArgs({
226
+ prompt: fullPrompt, provider: provider.name, model, thinking, sessionId, isResume, sessionDir,
227
+ bridge: servers.length ? (ctx._bridgePath || BRIDGE_PATH) : null,
228
+ });
229
+
230
+ const reply = await runPi({
231
+ args,
232
+ cwd: ctx.cwd,
233
+ env: childEnv,
234
+ timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
235
+ spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
236
+ });
237
+ // Empty text with a clean exit is a silent turn; the run loop treats it
238
+ // as NO_REPLY-shaped and re-delivers on its own rules.
239
+ return { text: reply.text, newSessionId: sessionId };
240
+ },
241
+ };