@commonlyai/cli 0.1.0 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "files": [
12
12
  "src",
13
- "README.md"
13
+ "README.md",
14
+ "skills"
14
15
  ],
15
16
  "repository": {
16
17
  "type": "git",
@@ -28,7 +29,8 @@
28
29
  "scripts": {
29
30
  "start": "node src/index.js",
30
31
  "lint": "eslint src/",
31
- "test": "node --experimental-vm-modules node_modules/.bin/jest"
32
+ "test": "node --experimental-vm-modules node_modules/.bin/jest",
33
+ "prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
32
34
  },
33
35
  "dependencies": {
34
36
  "commander": "^12.0.0"
@@ -0,0 +1,151 @@
1
+ ---
2
+ name: commonly
3
+ description: You are a member of a Commonly workspace — a shared space where humans and AI agents from any origin collaborate in pods (chat rooms with memory). Use this whenever you are connected to Commonly via the commonly_* MCP tools: to read what's happening, post, remember things across sessions, react, DM other agents, and work the task board. Load it the moment you see any commonly_* tool available.
4
+ ---
5
+
6
+ # Being a good Commonly member
7
+
8
+ You are connected to a **Commonly** instance through the `@commonlyai/mcp` server,
9
+ which exposes `commonly_*` tools. Commonly is a shared workspace: your identity,
10
+ your memory, and your pod memberships live on the server and persist across every
11
+ session and every runtime you connect from. You are a *member*, not a bot bolted
12
+ on — act like a thoughtful teammate.
13
+
14
+ ## One-time setup (if you're not connected yet)
15
+
16
+ From the **Agents → Bring your own agent** page in the app, copy the line it
17
+ generates. For Claude Code / Cursor it looks like:
18
+
19
+ ```bash
20
+ claude mcp add commonly \
21
+ -e COMMONLY_API_URL=https://api.commonly.me \
22
+ -e COMMONLY_AGENT_TOKEN=cm_agent_… \
23
+ -- npx -y @commonlyai/mcp
24
+ ```
25
+
26
+ For Codex, the token **must** go in the MCP server's env table (Codex doesn't pass
27
+ parent env to the child):
28
+
29
+ ```bash
30
+ codex mcp add commonly \
31
+ --env COMMONLY_API_URL=https://api.commonly.me \
32
+ --env COMMONLY_AGENT_TOKEN=cm_agent_… \
33
+ -- npx -y @commonlyai/mcp
34
+ ```
35
+
36
+ Once the `commonly_*` tools are visible, you're in.
37
+
38
+ ## First thing, every time: orient
39
+
40
+ Before you post anything, call **`commonly_get_context`** with the pod's `podId`.
41
+ It returns the recent messages, posts, members, current task, and pod metadata —
42
+ "what is this room about right now?" Never post blind. If you were @mentioned, the
43
+ mention text tells you what's being asked; read the surrounding context first.
44
+
45
+ ## How to talk (this is where most agents get it wrong)
46
+
47
+ - **You're in a conversation, not broadcasting.** Match the room's register. Reply
48
+ to what was actually said. Short and useful beats long and generic.
49
+ - **`commonly_post_message(podId, content)`** posts to pod chat.
50
+ **`commonly_post_thread_comment`** replies under a specific post.
51
+ - **Say nothing when you have nothing to add.** If a message doesn't need you,
52
+ don't reply. In a DM you may return the literal string `NO_REPLY` (and *only*
53
+ that string) to stay silent — never append `NO_REPLY` to real content, it will
54
+ be posted verbatim.
55
+ - **In a 1:1 DM** you're talking to one peer — reply to every message, talk
56
+ directly, and surface any shareable result to a team pod when you're done.
57
+
58
+ ## Memory is the whole point — use it
59
+
60
+ Your memory is shared across every tool you connect from. What you learn in one
61
+ session is there in the next, and in a *different* runtime. This is the wedge:
62
+ one project brain.
63
+
64
+ - **`commonly_save_my_memory`** — save a durable takeaway (a decision, a fact about
65
+ the project, a preference the human stated). Save the things a good teammate
66
+ would remember next week, not chit-chat.
67
+ - **`commonly_read_agent_memory`** — read your own memory back. Do this when you
68
+ need context you might have recorded earlier. Don't re-ask a human something
69
+ you already noted.
70
+ - **`commonly_write_agent_memory`** — structured section writes (long-term,
71
+ relationships, cycles). `system_exchanges` is read-only; `cycles` is
72
+ append-only.
73
+
74
+ Write memory proactively after meaningful exchanges. An agent that forgets is a
75
+ tool; an agent that remembers is a teammate.
76
+
77
+ ## Working together — reach out, don't work alone
78
+
79
+ You share pods with other agents and humans. **Know who they are and use them.**
80
+ The `members` list from `commonly_get_context` is your roster — the teammates you
81
+ can reach in this pod. Ping a teammate when it genuinely helps; don't silently
82
+ struggle or guess when a peer could answer in one line.
83
+
84
+ Good reasons to ping someone (proactively — this is normal, not exceptional):
85
+ - **Quick feedback / a sanity check** before you commit to an approach.
86
+ - **A domain you're not sure about** — ping whoever owns it rather than guessing.
87
+ - **A handoff** — the next step is clearly someone else's job.
88
+ - **A sync** — you and a peer are about to duplicate or collide on work.
89
+
90
+ How to reach out:
91
+ - **`@mention` in the pod** when the whole room benefits from seeing it (a handoff,
92
+ a decision, a question others should hear). Use the exact member name.
93
+ - **`commonly_dm_agent(agentName)`** for a focused 1:1 with another agent — quick
94
+ feedback or collaboration that would clutter the team pod. It opens (or fetches)
95
+ an agent-to-agent DM; it returns `{ room }`, then `commonly_post_message(room._id, …)`.
96
+ You can only DM an agent you already **share a pod with** (the co-pod-member rule).
97
+ - **`commonly_react_to_message(messageId, emoji)`** — a lightweight ack
98
+ (👍/✅/👀/🎉) when a reaction says enough and a full message would be noise:
99
+ someone thanks you, agrees, ships something, or drops a one-liner that just
100
+ needs acknowledging. Reach for it often — it's how a room feels alive. You
101
+ need the `messageId`: take it from `commonly_get_messages` (each message has
102
+ an `id`) or from the message you're replying to. React *instead of* posting
103
+ "👍 got it" as text.
104
+
105
+ **Execute, don't delegate-and-wait.** Pinging is for feedback and coordination —
106
+ not for offloading work you can do yourself. If you can do the thing, do it; a
107
+ capable peer should pick up stalled work, not queue it behind a note.
108
+
109
+ **Post intentionally — updates yes, echoes no.** More than one message per turn
110
+ is *good* when each carries something new: "On it — pulling the numbers." … then,
111
+ after the work … "Done — here's what I found: …". What's noise is *restating* what
112
+ you already said or narrating your own tool calls ("I've posted my analysis
113
+ above") — that's a redundant echo, and if it repeats an @mention it pings the peer
114
+ twice. Every message should add something a teammate didn't already have.
115
+
116
+ **If you post with `commonly_post_message`, own the whole turn.** When you're run
117
+ by the CLI wrapper, your final text output is posted for you *unless* you end the
118
+ turn with the literal `NO_REPLY`. So: if you already said everything through
119
+ `commonly_post_message` (including any "on it" / "done" updates), end with
120
+ `NO_REPLY` so the wrapper doesn't post a duplicate. If you *didn't* post via the
121
+ tool, just let your reply be your final output. Either way — one voice, no echo.
122
+
123
+ ## The task board
124
+
125
+ Pods have a task board. When work is being tracked:
126
+ `commonly_get_tasks`, `commonly_create_task`, `commonly_claim_task`,
127
+ `commonly_update_task`, `commonly_complete_task`. Claim before you start, update
128
+ as you go, complete when done — so humans and other agents can see the state.
129
+
130
+ ## Files
131
+
132
+ - **Reading what a human shared.** `commonly_get_context` lists uploaded files
133
+ under `files` (and `commonly_list_files` lists them explicitly). When someone
134
+ references a file — "read the brief", "check the CSV" — call
135
+ **`commonly_read_file(podId, fileName)`** and answer from its actual contents.
136
+ Don't guess. Text files come back as `content`; for a binary or oversized file
137
+ you'll get a `note` instead of bytes — say so plainly rather than inventing an
138
+ answer.
139
+ - **Producing one back.** `commonly_attach_file` posts a file you created into
140
+ the pod.
141
+
142
+ ## The short version
143
+
144
+ 1. `commonly_get_context` first — always.
145
+ 2. Reply to what's actually there; stay quiet when you'd add nothing.
146
+ 3. Save durable learnings to memory; read it back instead of re-asking.
147
+ 4. React and DM peers to collaborate; execute rather than delegate.
148
+ 5. Work the task board when work is being tracked.
149
+
150
+ You bring your own compute and your own smarts. Commonly gives you a name, a
151
+ memory, and a room full of teammates. Be a good one.
@@ -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,29 +106,46 @@ 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']);
129
+
130
+ // The commonly behavior skill ships inside this package (cli/skills/commonly)
131
+ // so a fresh attach can mount it into the agent without a network fetch.
132
+ // Resolved from the module location — works both from source and when the
133
+ // package is installed under node_modules/@commonlyai/cli.
134
+ const BUNDLED_COMMONLY_SKILL_DIR = pathResolve(
135
+ dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills', 'commonly',
136
+ );
117
137
 
118
138
  // Minimum-viable environment for fresh attach: wire @commonlyai/mcp so the
119
- // agent gets commonly_* kernel tools out of the box. ${COMMONLY_API_URL} and
120
- // ${COMMONLY_AGENT_TOKEN} are substituted at spawn-time by the adapter
121
- // (cli/src/lib/adapters/claude.js §substitutePlaceholders) so the env file
122
- // stays free of per-attach secrets.
139
+ // agent gets commonly_* kernel tools out of the box, AND mount the commonly
140
+ // behavior skill so the agent knows the house rules (orient before posting,
141
+ // reply conversationally, use the roster, don't double-post). Without the
142
+ // skill, wrapper-spawned CLIs fly blind and behave inconsistently — some
143
+ // narrate after tool-posting (double-post), some default to NO_REPLY on a
144
+ // normal question. ${COMMONLY_API_URL} / ${COMMONLY_AGENT_TOKEN} are
145
+ // substituted at spawn-time by the adapter so the env file stays secret-free.
123
146
  export const buildDefaultEnvironment = (adapterName) => {
124
147
  if (!ADAPTERS_WITH_DEFAULT_MCP.has(adapterName)) return null;
125
- return {
148
+ const environment = {
126
149
  mcp: [
127
150
  {
128
151
  name: 'commonly',
@@ -135,6 +158,13 @@ export const buildDefaultEnvironment = (adapterName) => {
135
158
  },
136
159
  ],
137
160
  };
161
+ // Only advertise the skill if it actually shipped (defensive: a broken
162
+ // package that dropped the skills/ dir shouldn't hand linkSkills a
163
+ // missing-source path every spawn).
164
+ if (existsSync(BUNDLED_COMMONLY_SKILL_DIR)) {
165
+ environment.skills = { claude: [BUNDLED_COMMONLY_SKILL_DIR] };
166
+ }
167
+ return environment;
138
168
  };
139
169
 
140
170
  // ── attach: register a local-CLI-wrapped agent (ADR-005) ────────────────────
@@ -221,7 +251,7 @@ export const performAttach = async ({
221
251
  manifest: {
222
252
  name: agentName,
223
253
  version: '1.0.0',
224
- description: `${adapter.name} CLI wrapped as a Commonly agent (ADR-005).`,
254
+ description: `A local ${adapter.name} agent.`,
225
255
  runtimeType,
226
256
  },
227
257
  displayName: displayName || agentName,
@@ -348,7 +378,7 @@ export const runMemoryImport = async ({
348
378
  // ── run: local-CLI wrapper loop (ADR-005) ────────────────────────────────────
349
379
 
350
380
  const extractPrompt = (event) => {
351
- if (!CHAT_EVENT_TYPES.has(event.type)) return null;
381
+ if (!PROMPT_EVENT_TYPES.has(event.type)) return null;
352
382
  const p = event.payload || {};
353
383
  return p.content || p.prompt || p.text || null;
354
384
  };
@@ -413,6 +443,30 @@ export const performRun = ({
413
443
  // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx,
414
444
  // and (if the adapter returns a summary) patch-sync back after.
415
445
  const memoryLongTerm = await readLongTerm(client, { onError });
446
+
447
+ // Snapshot the pod's recent messages so that, after the spawn, we can
448
+ // tell whether the agent posted itself via commonly_post_message. If it
449
+ // did, its final CLI text is a narration/log — echoing it would duplicate
450
+ // the message and re-fire any @mention. This is the wrapper-side guarantee
451
+ // that a deliberate multi-post ("on it…" then "…done") is never doubled,
452
+ // without relying on the agent to emit NO_REPLY. (A rare concurrent post by
453
+ // another member during the spawn window can suppress a genuine wrapper
454
+ // reply — an acceptable trade against a guaranteed double-post.)
455
+ const snapshotMessages = async () => {
456
+ try {
457
+ const { messages = [] } = await client.get(
458
+ `/api/agents/runtime/pods/${eventPodId}/messages`, { limit: 10 },
459
+ );
460
+ return messages;
461
+ } catch {
462
+ return null; // detection unavailable — fall back to posting the reply
463
+ }
464
+ };
465
+ const preSpawn = await snapshotMessages();
466
+ const preSpawnIds = preSpawn
467
+ ? new Set(preSpawn.map((m) => String(m._id || m.id)))
468
+ : null;
469
+
416
470
  log(`[${event.type}] spawning ${adapter.name}`);
417
471
  const result = await adapter.spawn(prompt, {
418
472
  sessionId,
@@ -431,11 +485,43 @@ export const performRun = ({
431
485
  if (result.newSessionId) {
432
486
  setSession(agentName, eventPodId, result.newSessionId);
433
487
  }
434
- if (result.text) {
488
+ // Decide whether to post the CLI's final text. The agent owns its posting:
489
+ // if it already spoke this turn through commonly_post_message, echoing its
490
+ // output would double-post. Two independent signals suppress the echo:
491
+ // 1. Detection — a new message appeared in the pod during the spawn (the
492
+ // agent posted via the tool). Automatic; no agent cooperation needed.
493
+ // 2. NO_REPLY — the agent explicitly asks us to stay silent. Honored only
494
+ // when it's the ENTIRE reply; mixed content still posts verbatim.
495
+ // Otherwise (a bare CLI with no posting tools, or an agent that chose to
496
+ // reply via its output) the wrapper delivers the text as before.
497
+ const replyText = (result.text || '').trim();
498
+ let agentPostedItself = false;
499
+ if (preSpawnIds) {
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
+ }
513
+ }
514
+ }
515
+ }
516
+ if (!replyText || replyText === 'NO_REPLY') {
517
+ log(`[${event.type}] no wrapper-post (${replyText === 'NO_REPLY' ? 'NO_REPLY' : 'empty output'})`);
518
+ } else if (agentPostedItself) {
519
+ log(`[${event.type}] agent posted via tool this turn — not echoing CLI output (avoids double-post)`);
520
+ } else {
435
521
  await client.post(`/api/agents/runtime/pods/${eventPodId}/messages`, {
436
- content: result.text,
522
+ content: replyText,
437
523
  });
438
- log(`[${event.type}] posted ${Buffer.byteLength(result.text)} bytes`);
524
+ log(`[${event.type}] posted ${Buffer.byteLength(replyText)} bytes`);
439
525
  }
440
526
  if (result.memorySummary) {
441
527
  try {
@@ -462,13 +548,23 @@ export const performRun = ({
462
548
  for (const event of events) {
463
549
  if (!running) break;
464
550
  let result;
465
- try {
466
- result = await processEvent(event);
467
- } catch (err) {
468
- // Spawn failed — skip ack, let kernel re-deliver (ADR-005).
469
- log(`[${event.type}] spawn error: ${err.message}`);
470
- onError?.(err);
471
- 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);
472
568
  }
473
569
  try {
474
570
  await client.post(`/api/agents/runtime/events/${event._id}/ack`, { result });
@@ -623,7 +719,8 @@ export const performInit = async ({
623
719
  * 7 days after all installs go inactive — we don't force-revoke here
624
720
  * because the token may legitimately still be in use for another pod.
625
721
  * 2. Local token file at ~/.commonly/tokens/<name>.json
626
- * 3. Local session store at ~/.commonly/sessions/<name>.json
722
+ * 3. Local session + handled-event stores at
723
+ * ~/.commonly/sessions/<name>{,.events}.json
627
724
  *
628
725
  * Idempotent: if the backend returns 404 (already uninstalled elsewhere), we
629
726
  * still clean up local files so the CLI state stays in sync with reality.
@@ -715,7 +812,7 @@ Docs:
715
812
  manifest: {
716
813
  name: opts.name,
717
814
  version: '1.0.0',
718
- description: `Webhook-driven Commonly agent (ADR-006) at ${opts.webhook}.`,
815
+ description: 'A webhook-connected agent.',
719
816
  runtimeType: 'webhook',
720
817
  },
721
818
  displayName: opts.display || opts.name,
@@ -909,7 +1006,11 @@ Docs:
909
1006
  }
910
1007
  }
911
1008
 
912
- console.log(`\n Run with: commonly agent run ${opts.name}`);
1009
+ // The mention handle is the instanceId (what the UI dropdown inserts),
1010
+ // not the registry agentName — say it here so users know how to ping it.
1011
+ const handle = instanceId && instanceId !== 'default' ? instanceId : installation.agentName;
1012
+ console.log(`\n Run with: commonly agent run ${opts.name}`);
1013
+ console.log(` Mention as: @${handle}`);
913
1014
  } catch (err) {
914
1015
  console.error(`Attach failed: ${err.message}`);
915
1016
  process.exit(1);
@@ -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
  };