@commonlyai/cli 0.1.1 → 0.1.2

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,8 +1,8 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "license": "Apache-2.0",
5
- "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
5
+ "description": "The Commonly CLI connect agents, manage pods, iterate fast",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
8
8
  "bin": {
@@ -21,7 +21,13 @@ import { getToken, resolveInstanceUrl } from '../lib/config.js';
21
21
  import { startPoller } from '../lib/poller.js';
22
22
  import { startWebhookServer, forwardToLocalWebhook } from '../lib/webhook-server.js';
23
23
  import { getAdapter, listAdapterNames } from '../lib/adapters/index.js';
24
- import { getSession, setSession, clearSessions } from '../lib/session-store.js';
24
+ import {
25
+ getSession,
26
+ setSession,
27
+ clearSessions,
28
+ wasEventHandled,
29
+ recordHandledEvent,
30
+ } from '../lib/session-store.js';
25
31
  import { readLongTerm, syncBack } from '../lib/memory-bridge.js';
26
32
  import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
27
33
  import { detectSkills, importSkills } from '../lib/skills-import.js';
@@ -100,20 +106,26 @@ export const listLocalAgents = () => {
100
106
  .filter(Boolean);
101
107
  };
102
108
 
103
- // Event types that carry a human/agent-authored prompt the wrapper should
104
- // forward to the CLI. Other event types (heartbeat, delivery, etc.) are acked
105
- // as no_action even if they happen to carry `content` in their payload.
106
- const CHAT_EVENT_TYPES = new Set(['chat.mention', 'message.posted', 'dm.message']);
109
+ // Event types that carry a prompt the wrapper should forward to the CLI.
110
+ // Other event types (heartbeat, delivery, etc.) are acked as no_action even
111
+ // if they happen to carry `content` in their payload.
112
+ const PROMPT_EVENT_TYPES = new Set([
113
+ 'chat.mention',
114
+ 'message.posted',
115
+ 'dm.message',
116
+ 'first_contact',
117
+ ]);
107
118
 
108
119
  // ── default environment for adapters that benefit from auto-MCP wiring ─────
109
120
 
110
121
  // Adapters that can consume `mcp[]` from the resolved environment spec.
111
- // `claude` reads it via --mcp-config (see adapters/claude.js). `codex` and
112
- // `stub` do not (codex uses ~/.codex/config.toml at boot, set up server-side
113
- // for cloud-codex; the local codex wrapper doesn't ship MCP wiring yet —
114
- // follow-up after #440). Returning null means "no default" the wrapper
115
- // proceeds with environment=null exactly like before this change.
116
- const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude']);
122
+ // `claude` reads it via --mcp-config (see adapters/claude.js); `codex` via
123
+ // `-c mcp_servers.*` config overrides (see adapters/codex.js added after
124
+ // the 2026-07-22 as-operator attribution incident, where an MCP-less codex
125
+ // agent posted through the operator's CLI profile because it had no
126
+ // commonly_* tools of its own). `stub` does not. Returning null means "no
127
+ // default" — the wrapper proceeds with environment=null exactly like before.
128
+ const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex']);
117
129
 
118
130
  // The commonly behavior skill ships inside this package (cli/skills/commonly)
119
131
  // so a fresh attach can mount it into the agent without a network fetch.
@@ -366,7 +378,7 @@ export const runMemoryImport = async ({
366
378
  // ── run: local-CLI wrapper loop (ADR-005) ────────────────────────────────────
367
379
 
368
380
  const extractPrompt = (event) => {
369
- if (!CHAT_EVENT_TYPES.has(event.type)) return null;
381
+ if (!PROMPT_EVENT_TYPES.has(event.type)) return null;
370
382
  const p = event.payload || {};
371
383
  return p.content || p.prompt || p.text || null;
372
384
  };
@@ -432,7 +444,7 @@ export const performRun = ({
432
444
  // and (if the adapter returns a summary) patch-sync back after.
433
445
  const memoryLongTerm = await readLongTerm(client, { onError });
434
446
 
435
- // Snapshot the pod's recent message ids so that, after the spawn, we can
447
+ // Snapshot the pod's recent messages so that, after the spawn, we can
436
448
  // tell whether the agent posted itself via commonly_post_message. If it
437
449
  // did, its final CLI text is a narration/log — echoing it would duplicate
438
450
  // the message and re-fire any @mention. This is the wrapper-side guarantee
@@ -440,17 +452,20 @@ export const performRun = ({
440
452
  // without relying on the agent to emit NO_REPLY. (A rare concurrent post by
441
453
  // another member during the spawn window can suppress a genuine wrapper
442
454
  // reply — an acceptable trade against a guaranteed double-post.)
443
- const snapshotMessageIds = async () => {
455
+ const snapshotMessages = async () => {
444
456
  try {
445
457
  const { messages = [] } = await client.get(
446
458
  `/api/agents/runtime/pods/${eventPodId}/messages`, { limit: 10 },
447
459
  );
448
- return new Set(messages.map((m) => String(m._id || m.id)));
460
+ return messages;
449
461
  } catch {
450
462
  return null; // detection unavailable — fall back to posting the reply
451
463
  }
452
464
  };
453
- const preSpawnIds = await snapshotMessageIds();
465
+ const preSpawn = await snapshotMessages();
466
+ const preSpawnIds = preSpawn
467
+ ? new Set(preSpawn.map((m) => String(m._id || m.id)))
468
+ : null;
454
469
 
455
470
  log(`[${event.type}] spawning ${adapter.name}`);
456
471
  const result = await adapter.spawn(prompt, {
@@ -482,10 +497,19 @@ export const performRun = ({
482
497
  const replyText = (result.text || '').trim();
483
498
  let agentPostedItself = false;
484
499
  if (preSpawnIds) {
485
- const postSpawnIds = await snapshotMessageIds();
486
- if (postSpawnIds) {
487
- for (const id of postSpawnIds) {
488
- if (!preSpawnIds.has(id)) { agentPostedItself = true; break; }
500
+ const postSpawn = await snapshotMessages();
501
+ if (postSpawn) {
502
+ for (const m of postSpawn) {
503
+ // Only a NEW message from a bot user counts as "the agent posted
504
+ // itself". A new human-authored message must NOT suppress the echo:
505
+ // it is either a human typing mid-turn, or the agent misusing an
506
+ // operator CLI profile / human token (the 2026-07-22 as-operator
507
+ // attribution incident) — in both cases the wrapper still delivers
508
+ // the reply under the agent's own identity.
509
+ if (!preSpawnIds.has(String(m._id || m.id)) && m.isBot) {
510
+ agentPostedItself = true;
511
+ break;
512
+ }
489
513
  }
490
514
  }
491
515
  }
@@ -524,13 +548,23 @@ export const performRun = ({
524
548
  for (const event of events) {
525
549
  if (!running) break;
526
550
  let result;
527
- try {
528
- result = await processEvent(event);
529
- } catch (err) {
530
- // Spawn failed — skip ack, let kernel re-deliver (ADR-005).
531
- log(`[${event.type}] spawn error: ${err.message}`);
532
- onError?.(err);
533
- continue;
551
+ if (wasEventHandled(agentName, event._id)) {
552
+ result = { outcome: 'no_action', reason: 'duplicate-delivery' };
553
+ log(`[${event.type}] duplicate delivery ${event._id} — skipping spawn and re-acking`);
554
+ } else {
555
+ try {
556
+ result = await processEvent(event);
557
+ } catch (err) {
558
+ // Spawn failed — do not record or ack, so the kernel can re-deliver
559
+ // the event after the local runtime recovers (ADR-005).
560
+ log(`[${event.type}] spawn error: ${err.message}`);
561
+ onError?.(err);
562
+ continue;
563
+ }
564
+ // Record after successful processing but before ack. If the ack
565
+ // fails, the next delivery is skipped and re-acked instead of
566
+ // burning a second model turn for work that already completed.
567
+ recordHandledEvent(agentName, event._id);
534
568
  }
535
569
  try {
536
570
  await client.post(`/api/agents/runtime/events/${event._id}/ack`, { result });
@@ -685,7 +719,8 @@ export const performInit = async ({
685
719
  * 7 days after all installs go inactive — we don't force-revoke here
686
720
  * because the token may legitimately still be in use for another pod.
687
721
  * 2. Local token file at ~/.commonly/tokens/<name>.json
688
- * 3. Local session store at ~/.commonly/sessions/<name>.json
722
+ * 3. Local session + handled-event stores at
723
+ * ~/.commonly/sessions/<name>{,.events}.json
689
724
  *
690
725
  * Idempotent: if the backend returns 404 (already uninstalled elsewhere), we
691
726
  * still clean up local files so the CLI state stays in sync with reality.
@@ -71,10 +71,64 @@ const buildPrompt = (prompt, memoryLongTerm) => {
71
71
  return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
72
72
  };
73
73
 
74
+ // ── MCP wiring — codex consumes MCP servers via `-c mcp_servers.*` overrides ─
75
+ //
76
+ // Same substitution contract as claude.js (see that file for the placeholder
77
+ // rationale): ${COMMONLY_AGENT_TOKEN} / ${COMMONLY_API_URL} in the declared
78
+ // env spec are replaced with the wrapper's per-(agent, pod) runtime values at
79
+ // spawn time. Before this block the codex adapter silently ignored
80
+ // `environment.mcp`, so a codex agent had NO sanctioned posting tool — the
81
+ // 2026-07-22 as-operator attribution incident was a codex agent falling back
82
+ // to the operator's CLI profile because commonly_* tools were never wired.
83
+ //
84
+ // Codex-specific constraints:
85
+ // - stdio/command servers only (no url transport here); url-only entries
86
+ // are skipped rather than half-wired.
87
+ // - Declared env MUST ride inside the mcp_servers.<name>.env table — codex
88
+ // does not pass its parent env to the MCP child it spawns (the PR #398
89
+ // cloud-codex lesson; same failure mode locally).
90
+ const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
91
+ const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
92
+
93
+ const substitutePlaceholders = (value, ctx) => {
94
+ if (typeof value !== 'string') return value;
95
+ if (!value.includes('${COMMONLY_')) return value;
96
+ const subs = {
97
+ COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
98
+ COMMONLY_API_URL: ctx.instanceUrl || '',
99
+ COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
100
+ };
101
+ return value.replace(PLACEHOLDER_RE, (whole, key) => (
102
+ SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole
103
+ ));
104
+ };
105
+
106
+ // JSON string escaping is a valid subset of TOML basic-string escaping, so
107
+ // JSON.stringify doubles as the TOML quoter for command/args/env values.
108
+ const toml = (s) => JSON.stringify(String(s));
109
+
110
+ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
111
+ const flags = [];
112
+ for (const server of mcpServers || []) {
113
+ if (!server?.name || !Array.isArray(server.command) || !server.command.length) continue;
114
+ const [command, ...rest] = server.command.map((a) => substitutePlaceholders(a, ctx));
115
+ flags.push('-c', `mcp_servers.${server.name}.command=${toml(command)}`);
116
+ if (rest.length) {
117
+ flags.push('-c', `mcp_servers.${server.name}.args=[${rest.map(toml).join(',')}]`);
118
+ }
119
+ const envEntries = Object.entries(server.env || {})
120
+ .map(([k, v]) => `${k} = ${toml(substitutePlaceholders(v, ctx))}`);
121
+ if (envEntries.length) {
122
+ flags.push('-c', `mcp_servers.${server.name}.env={${envEntries.join(', ')}}`);
123
+ }
124
+ }
125
+ return flags;
126
+ };
127
+
74
128
  // Build the argv after the `codex` binary. Resume vs new turn is a
75
129
  // subcommand-level distinction in modern codex, not an option flag — keep
76
130
  // that detail isolated here so the spawn path stays linear.
77
- const buildArgs = ({ sessionId, prompt, outputFile }) => {
131
+ const buildArgs = ({ sessionId, prompt, outputFile, mcpFlags = [] }) => {
78
132
  // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
79
133
  // bubblewrap (bwrap) sandbox + approval prompts. bwrap needs CAP_SYS_ADMIN
80
134
  // or unprivileged user-namespaces — neither available to standard k8s
@@ -90,6 +144,7 @@ const buildArgs = ({ sessionId, prompt, outputFile }) => {
90
144
  '--json',
91
145
  '--skip-git-repo-check',
92
146
  '--dangerously-bypass-approvals-and-sandbox',
147
+ ...mcpFlags,
93
148
  '-o',
94
149
  outputFile,
95
150
  ];
@@ -219,6 +274,10 @@ export default {
219
274
  sessionId: ctx.sessionId || null,
220
275
  prompt: fullPrompt,
221
276
  outputFile,
277
+ mcpFlags: buildMcpOverrideArgs(ctx.environment?.mcp, {
278
+ runtimeToken: ctx.runtimeToken,
279
+ instanceUrl: ctx.instanceUrl,
280
+ }),
222
281
  });
223
282
 
224
283
  const { threadId } = await runCodex({
@@ -14,6 +14,11 @@
14
14
  * }
15
15
  *
16
16
  * CLIs without sessions simply never call setSession — getSession returns null.
17
+ *
18
+ * Handled runtime-event IDs are intentionally persisted in a separate
19
+ * `<agent>.events.json` file. Keeping the bounded event ring separate means
20
+ * session-map readers retain their existing on-disk shape while a wrapper
21
+ * restart can still suppress a re-delivered event whose ack previously failed.
17
22
  */
18
23
 
19
24
  import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs';
@@ -22,6 +27,9 @@ import { homedir } from 'os';
22
27
 
23
28
  const sessionsDir = () => join(homedir(), '.commonly', 'sessions');
24
29
  const sessionsFile = (agentName) => join(sessionsDir(), `${agentName}.json`);
30
+ const handledEventsFile = (agentName) => join(sessionsDir(), `${agentName}.events.json`);
31
+ const MAX_HANDLED_EVENTS = 500;
32
+ const handledEventsCache = new Map();
25
33
 
26
34
  const readAgent = (agentName) => {
27
35
  const file = sessionsFile(agentName);
@@ -38,6 +46,42 @@ const writeAgent = (agentName, state) => {
38
46
  writeFileSync(sessionsFile(agentName), JSON.stringify(state, null, 2), 'utf8');
39
47
  };
40
48
 
49
+ const readHandledEvents = (agentName) => {
50
+ const file = handledEventsFile(agentName);
51
+ if (!existsSync(file)) return [];
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
54
+ if (!Array.isArray(parsed)) return [];
55
+ return parsed
56
+ .filter((eventId) => typeof eventId === 'string' && eventId.length > 0)
57
+ .slice(-MAX_HANDLED_EVENTS);
58
+ } catch {
59
+ return [];
60
+ }
61
+ };
62
+
63
+ const getHandledEvents = (agentName) => {
64
+ const file = handledEventsFile(agentName);
65
+ // Tests, detach, or an operator may remove the file while this process is
66
+ // alive. Reset the mirror rather than retaining IDs that no longer exist on
67
+ // disk; a fresh process likewise hydrates from the persisted ring.
68
+ if (!existsSync(file)) {
69
+ const empty = { ids: [], set: new Set() };
70
+ handledEventsCache.set(agentName, empty);
71
+ return empty;
72
+ }
73
+ if (!handledEventsCache.has(agentName)) {
74
+ const ids = readHandledEvents(agentName);
75
+ handledEventsCache.set(agentName, { ids, set: new Set(ids) });
76
+ }
77
+ return handledEventsCache.get(agentName);
78
+ };
79
+
80
+ const writeHandledEvents = (agentName, ids) => {
81
+ if (!existsSync(sessionsDir())) mkdirSync(sessionsDir(), { recursive: true });
82
+ writeFileSync(handledEventsFile(agentName), JSON.stringify(ids, null, 2), 'utf8');
83
+ };
84
+
41
85
  export const getSession = (agentName, podId) => {
42
86
  if (!agentName || !podId) return null;
43
87
  return readAgent(agentName)[podId]?.sessionId || null;
@@ -50,7 +94,26 @@ export const setSession = (agentName, podId, sessionId) => {
50
94
  writeAgent(agentName, state);
51
95
  };
52
96
 
97
+ export const wasEventHandled = (agentName, eventId) => {
98
+ if (!agentName || !eventId) return false;
99
+ return getHandledEvents(agentName).set.has(String(eventId));
100
+ };
101
+
102
+ export const recordHandledEvent = (agentName, eventId) => {
103
+ if (!agentName || !eventId) return;
104
+ const normalizedId = String(eventId);
105
+ const current = getHandledEvents(agentName);
106
+ if (current.set.has(normalizedId)) return;
107
+
108
+ const ids = [...current.ids, normalizedId].slice(-MAX_HANDLED_EVENTS);
109
+ writeHandledEvents(agentName, ids);
110
+ handledEventsCache.set(agentName, { ids, set: new Set(ids) });
111
+ };
112
+
53
113
  export const clearSessions = (agentName) => {
54
114
  const file = sessionsFile(agentName);
55
115
  if (existsSync(file)) rmSync(file);
116
+ const eventsFile = handledEventsFile(agentName);
117
+ if (existsSync(eventsFile)) rmSync(eventsFile);
118
+ handledEventsCache.delete(agentName);
56
119
  };