@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 +2 -2
- package/src/commands/agent.js +63 -28
- package/src/lib/adapters/codex.js +60 -1
- package/src/lib/session-store.js +63 -0
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
|
-
"description": "The Commonly CLI
|
|
5
|
+
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
8
8
|
"bin": {
|
package/src/commands/agent.js
CHANGED
|
@@ -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 {
|
|
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
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
const
|
|
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)
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
460
|
+
return messages;
|
|
449
461
|
} catch {
|
|
450
462
|
return null; // detection unavailable — fall back to posting the reply
|
|
451
463
|
}
|
|
452
464
|
};
|
|
453
|
-
const
|
|
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
|
|
486
|
-
if (
|
|
487
|
-
for (const
|
|
488
|
-
|
|
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
|
-
|
|
528
|
-
result =
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
|
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({
|
package/src/lib/session-store.js
CHANGED
|
@@ -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
|
};
|