@commonlyai/cli 0.1.45 → 0.1.48

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.45",
3
+ "version": "0.1.48",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -33,6 +33,7 @@ import { pollRetryPolicy } from '../lib/poll-retry.js';
33
33
  import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
34
34
  import { detectSkills, importSkills } from '../lib/skills-import.js';
35
35
  import { parseEnvironmentFile, resolveWorkspace, validateEnvironmentSpec } from '../lib/environment.js';
36
+ import { ADAPTERS_WITH_DEFAULT_MCP, defaultMcpServers } from '../lib/default-environment.js';
36
37
  import {
37
38
  FOCUS_FRAME_MAX_CODE_POINTS,
38
39
  formatPodFocusFrame,
@@ -247,14 +248,9 @@ const PRIVATE_RESPONSE_EVENT_TYPES = new Set(['agent.ask', 'agent.ask.response']
247
248
 
248
249
  // ── default environment for adapters that benefit from auto-MCP wiring ─────
249
250
 
250
- // Adapters that can consume `mcp[]` from the resolved environment spec.
251
- // `claude` reads it via --mcp-config (see adapters/claude.js); `codex` via
252
- // `-c mcp_servers.*` config overrides (see adapters/codex.js added after
253
- // the 2026-07-22 as-operator attribution incident, where an MCP-less codex
254
- // agent posted through the operator's CLI profile because it had no
255
- // commonly_* tools of its own). `stub` does not. Returning null means "no
256
- // default" — the wrapper proceeds with environment=null exactly like before.
257
- const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi']);
251
+ // Which adapters can consume `mcp[]`, and the server declaration they get by
252
+ // default, live in lib/default-environment.js the daemon's per-seat token
253
+ // provisioning reads the same module, so the two can never drift apart.
258
254
  const CODEX_PERMISSION_PROFILE_MIN_VERSION = [0, 138, 0];
259
255
 
260
256
  const versionAtLeast = (version, minimum) => {
@@ -285,23 +281,11 @@ const BUNDLED_COMMONLY_SKILL_DIR = pathResolve(
285
281
  // reply conversationally, use the roster, don't double-post). Without the
286
282
  // skill, wrapper-spawned CLIs fly blind and behave inconsistently — some
287
283
  // narrate after tool-posting (double-post), some default to NO_REPLY on a
288
- // normal question. ${COMMONLY_API_URL} / ${COMMONLY_AGENT_TOKEN} are
289
- // substituted at spawn-time by the adapter so the env file stays secret-free.
284
+ // normal question. Returning null means "no default" — a non-consuming adapter
285
+ // proceeds with environment=null exactly like before.
290
286
  export const buildDefaultEnvironment = (adapterName) => {
291
287
  if (!ADAPTERS_WITH_DEFAULT_MCP.has(adapterName)) return null;
292
- const environment = {
293
- mcp: [
294
- {
295
- name: 'commonly',
296
- transport: 'stdio',
297
- command: ['npx', '-y', '@commonlyai/mcp@latest'],
298
- env: {
299
- COMMONLY_API_URL: '${COMMONLY_API_URL}',
300
- COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}',
301
- },
302
- },
303
- ],
304
- };
288
+ const environment = { mcp: defaultMcpServers(adapterName) };
305
289
  // Only advertise the skill if it actually shipped (defensive: a broken
306
290
  // package that dropped the skills/ dir shouldn't hand mountSkills a
307
291
  // missing-source path every spawn).
@@ -34,7 +34,7 @@
34
34
 
35
35
  import { spawn as childSpawn, spawnSync } from 'child_process';
36
36
  import { createHash, randomUUID } from 'crypto';
37
- import { mkdir, writeFile } from 'fs/promises';
37
+ import { mkdir, readdir, writeFile } from 'fs/promises';
38
38
  import { homedir } from 'os';
39
39
  import { dirname, join } from 'path';
40
40
  import { fileURLToPath } from 'url';
@@ -127,6 +127,24 @@ export const seatHome = (ctx) => {
127
127
  return ctx._piHome || join(homedir(), '.commonly', 'pi-homes', hash);
128
128
  };
129
129
 
130
+ /**
131
+ * Only a session pi wrote can be resumed. The wrapper persists one id per
132
+ * (agent, pod) across adapters, so a seat switched from codex hands pi the
133
+ * codex thread id on its first turn — pi answers `No session found` and the
134
+ * turn fails (sprint-impl's first spawn, 2026-09-18 05:06Z). pi's session
135
+ * files are `<timestamp>_<id>.jsonl` under the seat's session dir; an id with
136
+ * no file starts a fresh session under a new id, which the wrapper persists.
137
+ */
138
+ export const sessionExists = async (sessionDir, sessionId) => {
139
+ if (!sessionId) return false;
140
+ try {
141
+ const files = await readdir(sessionDir);
142
+ return files.some((name) => name.endsWith(`_${sessionId}.jsonl`) || name === `${sessionId}.jsonl`);
143
+ } catch {
144
+ return false;
145
+ }
146
+ };
147
+
130
148
  export const buildArgs = ({
131
149
  prompt, provider, model, thinking, sessionId, isResume, sessionDir, bridge,
132
150
  }) => [
@@ -196,9 +214,6 @@ export default {
196
214
  },
197
215
 
198
216
  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
217
  const provider = resolveProvider(ctx.environment);
203
218
  const model = ctx.environment?.model || DEFAULT_MODEL;
204
219
  const thinking = thinkingFor(ctx.environment?.effort);
@@ -213,6 +228,12 @@ export default {
213
228
  const sessionDir = join(home, 'sessions');
214
229
  await mkdir(agentDir, { recursive: true, mode: 0o700 });
215
230
  await mkdir(sessionDir, { recursive: true, mode: 0o700 });
231
+
232
+ // Resume only what pi wrote; anything else (a codex thread id from before
233
+ // the switch, a wiped home) starts fresh under a new id.
234
+ const isResume = await sessionExists(sessionDir, ctx.sessionId);
235
+ const sessionId = isResume ? ctx.sessionId : randomUUID();
236
+ const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: !isResume });
216
237
  await writeFile(join(agentDir, 'models.json'), `${JSON.stringify(buildModelsJson(provider, model), null, 2)}\n`, { mode: 0o600 });
217
238
 
218
239
  const servers = resolveMcpServers(ctx.environment?.mcp, ctx);
@@ -2,6 +2,8 @@ import { isDeepStrictEqual } from 'node:util';
2
2
  import { homedir } from 'node:os';
3
3
  import { isAbsolute, resolve as pathResolve } from 'node:path';
4
4
 
5
+ import { withDefaultMcpServer } from './default-environment.js';
6
+
5
7
  /**
6
8
  * ADR-026 Phase 2, slice 2: the resident supervision loop behind
7
9
  * `commonly daemon run`.
@@ -181,9 +183,14 @@ export const createDaemonSupervisor = ({
181
183
  adapterChanged = existing.adapter !== nextAdapter;
182
184
  }
183
185
  if (wanted) {
184
- const nextEnvironment = wanted.declared
186
+ const merged = wanted.declared
185
187
  ? wanted.value
186
188
  : { ...(existing.environment || {}), ...wanted.value };
189
+ // A server-declared environment replaces the local one, so a seat whose
190
+ // declaration carries no mcp[] would come back tool-less. Re-apply the
191
+ // shipped default here and below, or the seat silently loses every
192
+ // commonly_* tool the next time the UI edits its model (TASK-048).
193
+ const nextEnvironment = withDefaultMcpServer(merged, nextAdapter);
187
194
  const workspacePath = workspacePathFor(nextEnvironment);
188
195
  const nextRecord = {
189
196
  ...existing,
@@ -200,10 +207,32 @@ export const createDaemonSupervisor = ({
200
207
  }
201
208
  }
202
209
  if (adapterChanged) {
203
- saveToken(row.agentName, { ...existing, adapter: nextAdapter });
210
+ // An adapter that consumes mcp[] must not be started on a record that
211
+ // declares none, even when only the adapter itself changed.
212
+ const nextEnvironment = withDefaultMcpServer(existing.environment, nextAdapter);
213
+ saveToken(row.agentName, {
214
+ ...existing,
215
+ adapter: nextAdapter,
216
+ ...(nextEnvironment ? { environment: nextEnvironment } : {}),
217
+ });
204
218
  log('runtime adapter changed — restarting the seat to load it');
205
219
  return 'changed';
206
220
  }
221
+ if (!wanted) {
222
+ // A record written by a CLI older than the shipped default carries no
223
+ // mcp[] — the c4-smoke record was exactly this, and the operator had to
224
+ // hand-add the entry. Behind a row that declares nothing there is no
225
+ // other write path: the record is never touched again, so the seat
226
+ // stays tool-less for as long as it runs. Heal it here, and only when
227
+ // the environment actually changed — otherwise every tick rewrites the
228
+ // file and restarts the seat forever.
229
+ const nextEnvironment = withDefaultMcpServer(existing.environment, existing.adapter);
230
+ if (!isDeepStrictEqual(existing.environment || null, nextEnvironment || null)) {
231
+ saveToken(row.agentName, { ...existing, environment: nextEnvironment });
232
+ log('record predates the commonly MCP baseline — restarting the seat to load it');
233
+ return 'changed';
234
+ }
235
+ }
207
236
  return 'ready';
208
237
  }
209
238
  const requestedAdapter = row.runtime && typeof row.runtime === 'object'
@@ -246,6 +275,13 @@ export const createDaemonSupervisor = ({
246
275
  return false;
247
276
  }
248
277
  const environment = environmentFor(row);
278
+ // A seat installed server-side (no local `agent attach`) arrives with no
279
+ // mcp[] at all: without the default it spawns a CLI that has no commonly_*
280
+ // tools and cannot post. See lib/default-environment.js.
281
+ const recordEnvironment = withDefaultMcpServer(
282
+ environment ? environment.value : null,
283
+ adapter,
284
+ );
249
285
  saveToken(row.agentName, {
250
286
  agentName: row.agentName,
251
287
  instanceId: row.instanceId,
@@ -253,9 +289,9 @@ export const createDaemonSupervisor = ({
253
289
  instanceUrl: record.instanceUrl,
254
290
  podId: row.podIds?.[0] || null,
255
291
  adapter,
256
- ...(environment ? { environment: environment.value } : {}),
257
- ...(environment?.value ? (() => {
258
- const workspacePath = workspacePathFor(environment.value);
292
+ ...(recordEnvironment ? { environment: recordEnvironment } : {}),
293
+ ...(recordEnvironment ? (() => {
294
+ const workspacePath = workspacePathFor(recordEnvironment);
259
295
  return workspacePath ? { workspacePath } : {};
260
296
  })() : {}),
261
297
  });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The commonly MCP server every wrapper seat gets by default (ADR-008 `mcp[]`).
3
+ *
4
+ * One declaration, three consumers: `agent attach`, the COMMONLY_AGENT_TOKEN
5
+ * bootstrap in `agent run`, and the daemon's per-seat token provisioning. The
6
+ * third consumer was missing until 2026-09-18 — a seat installed server-side
7
+ * (nobody ever ran `agent attach` on that host) got a token record carrying
8
+ * runtime model/effort and no `mcp[]` at all, so the CLI it spawned had no
9
+ * `commonly_*` tools and could not post; the operator hand-added the entry to
10
+ * get the seat working (C4 run, TASK-048).
11
+ *
12
+ * Only adapters with a real `mcp[]` consumption path get a default:
13
+ * claude — `--mcp-config` (adapters/claude.js)
14
+ * codex — `-c mcp_servers.*` overrides (adapters/codex.js; added after the
15
+ * 2026-07-22 as-operator attribution incident, where an MCP-less
16
+ * codex agent posted through the operator's own CLI profile because
17
+ * it had no commonly_* tool of its own)
18
+ * pi — stdio servers from the environment spec (adapters/pi.js)
19
+ * `stub` has no consumption path and must keep being handed `environment: null`.
20
+ *
21
+ * The placeholders are substituted at spawn time by the adapter, so the
22
+ * declaration itself carries no secret and is safe to persist to a token file.
23
+ */
24
+
25
+ export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi']);
26
+
27
+ export const COMMONLY_MCP_SERVER_NAME = 'commonly';
28
+
29
+ export const commonlyMcpServer = () => ({
30
+ name: COMMONLY_MCP_SERVER_NAME,
31
+ transport: 'stdio',
32
+ command: ['npx', '-y', '@commonlyai/mcp@latest'],
33
+ env: {
34
+ COMMONLY_API_URL: '${COMMONLY_API_URL}',
35
+ COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}',
36
+ },
37
+ });
38
+
39
+ /** The default `mcp[]` for an adapter, or [] when it has no consumption path. */
40
+ export const defaultMcpServers = (adapterName) => (
41
+ ADAPTERS_WITH_DEFAULT_MCP.has(adapterName) ? [commonlyMcpServer()] : []
42
+ );
43
+
44
+ /**
45
+ * Ensure `environment` declares the commonly MCP server, touching nothing that
46
+ * is already declared.
47
+ *
48
+ * The predicate is "is there an mcp entry named commonly?", not "is mcp[]
49
+ * absent?" — the daemon projects live room grants into this same array, so a
50
+ * seat can arrive with `mcp: [<grant broker>]` and no kernel server, and it is
51
+ * exactly as tool-less as one with no `mcp` key at all.
52
+ *
53
+ * Returns the SAME reference when nothing needs adding, so callers can use the
54
+ * identity as a dirty check. A declared commonly entry is never replaced or
55
+ * duplicated — an operator's hand-set command (a pinned version, a staging
56
+ * checkout) wins over the shipped default — and a non-consuming adapter is
57
+ * returned untouched. A malformed (non-object) spec is not ours to repair.
58
+ */
59
+ export const withDefaultMcpServer = (environment, adapterName) => {
60
+ if (!ADAPTERS_WITH_DEFAULT_MCP.has(adapterName)) return environment;
61
+ if (environment !== null && environment !== undefined
62
+ && (typeof environment !== 'object' || Array.isArray(environment))) {
63
+ return environment;
64
+ }
65
+ const declared = Array.isArray(environment?.mcp) ? environment.mcp : null;
66
+ if (declared && declared.some((server) => server?.name === COMMONLY_MCP_SERVER_NAME)) {
67
+ return environment;
68
+ }
69
+ return { ...(environment || {}), mcp: [...(declared || []), commonlyMcpServer()] };
70
+ };