@commonlyai/cli 0.1.9 → 0.1.10

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.9",
3
+ "version": "0.1.10",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -710,6 +710,7 @@ export const performRun = ({
710
710
  retryJitterRatio,
711
711
  claimLeaseSeconds = 90,
712
712
  cascadeCap = 3,
713
+ cascadeAddressedGrace = 2,
713
714
  cascadeResetMs = 10 * 60 * 1000,
714
715
  chatCharLimit = 400,
715
716
  maxChatChunks = 3,
@@ -729,7 +730,11 @@ export const performRun = ({
729
730
  const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
730
731
  // Per-seat cascade state — lives with the process, like the session store.
731
732
  // A wrapper restart forgets the streak; the decay window covers that gap.
732
- const cascadeGovernor = createCascadeGovernor({ cap: cascadeCap, resetMs: cascadeResetMs });
733
+ const cascadeGovernor = createCascadeGovernor({
734
+ cap: cascadeCap,
735
+ addressedGrace: cascadeAddressedGrace,
736
+ resetMs: cascadeResetMs,
737
+ });
733
738
  // Fairness: recent broadcast-race winners start the next race from the back.
734
739
  const claimHandicap = createClaimHandicap({ delayMs: claimYieldDelayMs });
735
740
 
@@ -798,11 +803,13 @@ export const performRun = ({
798
803
  // capped agent-triggered event is exactly the traffic we want dropped.
799
804
  // Human-triggered turns are never capped.
800
805
  const trigger = classifyTrigger(event, preSpawn);
801
- const admission = cascadeGovernor.admit(eventPodId, trigger);
806
+ const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
802
807
  if (!admission.allowed) {
803
808
  log(
804
809
  `[${event.type}] cascade cap: ${admission.streak} consecutive agent-triggered `
805
- + `turns in pod ${eventPodId} — standing down until a human speaks or the streak decays`,
810
+ + `turns in pod ${eventPodId}`
811
+ + (admission.addressed ? ' (addressed grace also spent)' : '')
812
+ + ' — standing down until a human speaks or the pod goes quiet for the reset window',
806
813
  );
807
814
  return { outcome: 'no_action', reason: 'cascade-cap' };
808
815
  }
@@ -1082,8 +1089,26 @@ export const performRun = ({
1082
1089
  circuitOpen: retry.circuitOpen,
1083
1090
  eventId: event._id,
1084
1091
  });
1085
- log(`[${event.type}] ${wrapped.message}`);
1086
- onError?.(wrapped);
1092
+ // ONE emission, not two. `wrapped.message` already opens with the
1093
+ // event type, so the log copy added a second prefix and a second
1094
+ // line of identical text into the same merged stream — enough that
1095
+ // `grep -c 'session limit'` returned 8 for 4 failures, and a reader
1096
+ // counting hits per event id saw 2 and inferred two deliveries.
1097
+ // On 2026-08-18 that count pointed at the wrong cause for #993:
1098
+ // two deliveries per event puts `attempts` at 2 and the requeue cap
1099
+ // one step away. It did not decide anything — the cap was ruled out
1100
+ // from the DB, where a capped event would have left a `failed` row
1101
+ // and none existed — but it corroborated the wrong theory, and the
1102
+ // doubling had to be spotted by hand before the count could be
1103
+ // discounted. A log that inflates is worse than one that is silent,
1104
+ // because it argues.
1105
+ //
1106
+ // Routed to the error channel when a caller provides one, and to
1107
+ // the log when it doesn't, so neither contract loses the failure:
1108
+ // an embedder that passes no `onError` still sees it, and `agent
1109
+ // run` — which always passes one — stops printing it twice.
1110
+ if (onError) onError(wrapped);
1111
+ else log(`[${event.type}] ${wrapped.message}`);
1087
1112
  break;
1088
1113
  }
1089
1114
  // Only a completed model turn proves the local runtime and delivery
@@ -1300,6 +1325,24 @@ export const performDetach = async ({
1300
1325
  return { backend: backendResult, localCleaned: true };
1301
1326
  };
1302
1327
 
1328
+ /**
1329
+ * Every line a seat emits lands in one file, because the fleet is launched as
1330
+ * `nohup commonly agent run <name> > <log> 2>&1`. Until now none of those lines
1331
+ * carried a time.
1332
+ *
1333
+ * That is not a cosmetic gap. On 2026-08-18 a fleet-wide quota stall destroyed
1334
+ * 38 queued events (#993), and the seat log was the ONLY surviving trace —
1335
+ * the kernel rows were deleted, so nothing else recorded that those turns had
1336
+ * been attempted. The log records `(4 consecutive)` and `next probe in 2.0m`
1337
+ * and gives no way to place either on a clock: the investigation had to date
1338
+ * events by decoding ObjectId prefixes instead, because the file that named
1339
+ * them could not say when.
1340
+ *
1341
+ * ISO-8601 so stamps sort lexically, diff cleanly, and line up with the
1342
+ * kernel's own timestamps without conversion.
1343
+ */
1344
+ const stamp = () => new Date().toISOString();
1345
+
1303
1346
  export const registerAgent = (program) => {
1304
1347
  const agent = program.command('agent').description('Manage agents');
1305
1348
 
@@ -1649,15 +1692,15 @@ Docs:
1649
1692
  record = await bootstrapAgentRecordFromEnv({
1650
1693
  name,
1651
1694
  adapterOverride: opts.adapter || null,
1652
- log: (line) => console.log(`[${name}] ${line}`),
1695
+ log: (line) => console.log(`${stamp()} [${name}] ${line}`),
1653
1696
  });
1654
1697
  } catch (err) {
1655
- console.error(err.message);
1698
+ console.error(`${stamp()} [${name}] ${err.message}`);
1656
1699
  process.exit(1);
1657
1700
  }
1658
1701
  if (record) {
1659
1702
  saveAgentToken(record.agentName, record);
1660
- console.log(`[${name}] bootstrapped ${tokenFile(record.agentName)} from COMMONLY_AGENT_TOKEN (adapter: ${record.adapter})`);
1703
+ console.log(`${stamp()} [${name}] bootstrapped ${tokenFile(record.agentName)} from COMMONLY_AGENT_TOKEN (adapter: ${record.adapter})`);
1661
1704
  } else {
1662
1705
  console.error(
1663
1706
  `No token for '${name}'. Either export COMMONLY_API_URL + COMMONLY_AGENT_TOKEN`
@@ -1674,7 +1717,14 @@ Docs:
1674
1717
  process.exit(1);
1675
1718
  }
1676
1719
 
1677
- console.log(`[${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
1720
+ // THE line to stamp, not just one of them. It is the first line of every
1721
+ // seat log and the truncation boundary — the fleet is launched as
1722
+ // `nohup … > log 2>&1`, so this banner is written at boot and everything
1723
+ // before it is gone. Stamped, it dates the restart from the log itself.
1724
+ // Unstamped, it took `ps -o lstart` to establish when nine seats came
1725
+ // back on 2026-08-18, and that only worked because the processes were
1726
+ // still alive — after the next restart that route is gone too.
1727
+ console.log(`${stamp()} [${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
1678
1728
 
1679
1729
  const { stop } = performRun({
1680
1730
  instanceUrl: record.instanceUrl,
@@ -1686,12 +1736,32 @@ Docs:
1686
1736
  environment: record.environment || null,
1687
1737
  workspacePath: record.workspacePath || null,
1688
1738
  intervalMs: parseInt(opts.interval, 10),
1689
- log: (line) => console.log(`[${name}] ${line}`),
1690
- onError: (err) => console.error(`[${name}] ${err.message}`),
1739
+ log: (line) => console.log(`${stamp()} [${name}] ${line}`),
1740
+ // Both sinks are stamped, and both must be — but not for the reason
1741
+ // this comment gave until now, which its own PR falsified.
1742
+ //
1743
+ // A spawn failure is routed to `onError` when a caller provides one and
1744
+ // to `log` only as the fallback, so in `agent run` — which always passes
1745
+ // one — EVERY failure line comes out of the error channel and none out
1746
+ // of the log. Stamping only `log:` would leave the entire failure class
1747
+ // undated, which is the class these stamps exist for.
1748
+ //
1749
+ // The two sinks also carry genuinely different text elsewhere: the
1750
+ // no-prompt skip logs a terse line and sends a detailed diagnostic, so
1751
+ // one stamped and one bare would date half of that pair.
1752
+ //
1753
+ // (History, because the reasoning moved twice. This originally argued
1754
+ // from a DOUBLE emission — the retry path called both sinks with
1755
+ // identical text, so `grep -c 'session limit'` returned 8 for 4 failures
1756
+ // and a per-event count read as two deliveries. That was true when the
1757
+ // stamps landed and false eleven minutes later, when the same PR
1758
+ // collapsed the duplication. The conclusion survived the premise; the
1759
+ // sentence did not, and nothing in the diff pointed at it.)
1760
+ onError: (err) => console.error(`${stamp()} [${name}] ${err.message}`),
1691
1761
  });
1692
1762
 
1693
1763
  process.on('SIGINT', () => {
1694
- console.log(`\n[${name}] stopping...`);
1764
+ console.log(`\n${stamp()} [${name}] stopping...`);
1695
1765
  stop();
1696
1766
  process.exit(0);
1697
1767
  });
@@ -474,7 +474,18 @@ export default {
474
474
  const sessionId = ctx.sessionId || randomUUID();
475
475
  const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
476
476
  const sessionFlag = isResume ? '--resume' : '--session-id';
477
- const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId];
477
+ // Model pin from the ADR-008 environment spec. Absent it, claude picks its
478
+ // own default — which is how a fleet of ten agents ended up running three
479
+ // different Opus versions that nobody chose and nothing recorded, with one
480
+ // seat named after a model it does not run.
481
+ //
482
+ // Computed ONCE and spread into BOTH arg builders. The session-recovery
483
+ // path at the bottom of this function constructs its own array, and a model
484
+ // present in one but not the other means a retry silently runs a different
485
+ // model than the turn it is replacing — the same drifting-copy shape that
486
+ // has bitten this codebase repeatedly.
487
+ const modelArgs = ctx.environment?.model ? ['--model', String(ctx.environment.model)] : [];
488
+ const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId, ...modelArgs];
478
489
 
479
490
  if (ctx.environment && ctx.cwd) {
480
491
  const skills = await mountSkills(ctx.environment, ctx.cwd);
@@ -529,7 +540,7 @@ export default {
529
540
  // session id poisons every subsequent event re-delivery.
530
541
  if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
531
542
  const freshId = randomUUID();
532
- const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId];
543
+ const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
533
544
  const retry = await prepareArgv(retryBase, {
534
545
  ...ctx,
535
546
  mcpConfigPath: mcpConfig?.file || null,
@@ -55,6 +55,13 @@ export const classifyTrigger = (event, recentMessages) => {
55
55
  return trigger.isBot ? 'agent' : 'human';
56
56
  };
57
57
 
58
+ // Direct-address event types: the seat was NAMED (explicit @, implicit human
59
+ // reply, or DM routing). A lost claim on these does not silence the seat —
60
+ // being chosen by a human outranks being beaten to a CAS. Broadcast wakes
61
+ // (message.posted) are the opposite: nobody asked for THIS seat, so a lost
62
+ // race is a free stand-down.
63
+ export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
64
+
58
65
  // ── cascade governor ────────────────────────────────────────────────────────
59
66
 
60
67
  /**
@@ -68,12 +75,19 @@ export const classifyTrigger = (event, recentMessages) => {
68
75
  * pod recovers on its own — a legitimate a2a handoff an hour later must not
69
76
  * inherit a stale cap).
70
77
  *
78
+ * NOTE on `resetMs`: it is a SILENCE window, not a decay. It requires
79
+ * `resetMs` with ZERO agent-triggered turns in the pod. Three chatty seats
80
+ * never leave a ten-minute hole, so in a busy room the only live release is a
81
+ * human turn. Say so plainly rather than letting "or the streak decays" imply
82
+ * a timer that will save you.
83
+ *
71
84
  * Split into admit/record so a spawn that fails (and will be redelivered)
72
85
  * never double-counts: admit() only reads, record() runs after a turn
73
86
  * actually completed. Human-triggered turns are always admitted.
74
87
  */
75
88
  export const createCascadeGovernor = ({
76
89
  cap = 3,
90
+ addressedGrace = 2,
77
91
  resetMs = 10 * 60 * 1000,
78
92
  now = Date.now,
79
93
  } = {}) => {
@@ -88,10 +102,23 @@ export const createCascadeGovernor = ({
88
102
  };
89
103
 
90
104
  return {
91
- admit(podId, trigger) {
92
- if (trigger !== 'agent') return { allowed: true, streak: 0 };
105
+ admit(podId, trigger, eventType) {
106
+ if (trigger !== 'agent') return { allowed: true, streak: 0, addressed: false };
93
107
  const s = stateFor(podId);
94
- return { allowed: s.streak < cap, streak: s.streak };
108
+ // Being NAMED outranks a mechanical brake the same judgement the claim
109
+ // path already makes forty lines down in agent.js. Without this, a peer
110
+ // can @mention a capped seat and get silence, with no signal to either
111
+ // side that anything was suppressed. Observed 2026-08-18: one seat took
112
+ // 51 wakes and 28 consecutive cap refusals, five of them chat.mention,
113
+ // and answered none of them.
114
+ //
115
+ // A GRACE, not an exemption: an unbounded pass would restore the exact
116
+ // A-mentions-B-mentions-A echo this governor exists to kill. Addressed
117
+ // turns still count toward the streak, so a mention loop terminates at
118
+ // cap + addressedGrace instead of never.
119
+ const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
120
+ const limit = addressed ? cap + addressedGrace : cap;
121
+ return { allowed: s.streak < limit, streak: s.streak, addressed };
95
122
  },
96
123
  record(podId, trigger) {
97
124
  if (trigger === 'human') {
@@ -107,12 +134,6 @@ export const createCascadeGovernor = ({
107
134
 
108
135
  // ── claim fairness ──────────────────────────────────────────────────────────
109
136
 
110
- // Direct-address event types: the seat was NAMED (explicit @, implicit human
111
- // reply, or DM routing). A lost claim on these does not silence the seat —
112
- // being chosen by a human outranks being beaten to a CAS. Broadcast wakes
113
- // (message.posted) are the opposite: nobody asked for THIS seat, so a lost
114
- // race is a free stand-down.
115
- export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
116
137
 
117
138
  // Frame prepended when an ADDRESSED seat lost the claim race: it still gets
118
139
  // its turn, but knows a peer is (probably) already answering — the bar for
@@ -29,8 +29,19 @@ import { homedir } from 'os';
29
29
  // exposes `$HOME` layout for zero server-side benefit. Callers pass
30
30
  // `envFileDir` as a separate argument to resolveWorkspace / mountSkills.
31
31
 
32
+ // `model` closes issue #774. Before it, the only way to pin a wrapper's model
33
+ // was `ANTHROPIC_MODEL` exported at `commonly agent run` time — per-process,
34
+ // never persisted, and silently dropped by any restart. Measured consequence on
35
+ // 2026-08-16: six of nine live seats had lost their assigned model, including
36
+ // ux-lead, which was supposed to be on Fable and had been running the CLI
37
+ // default for days with nothing recording the drift.
38
+ //
39
+ // Putting it in the env spec makes the model a persisted, server-side fact that
40
+ // survives restarts and is readable by the platform — which is what ADR-022's
41
+ // "persona and runtime are chosen separately" requires to mean anything for a
42
+ // BYO seat, and what lets an identity card answer "what is this running".
32
43
  const ALLOWED_TOP_KEYS = new Set([
33
- 'version', 'workspace', 'sandbox', 'skills', 'mcp',
44
+ 'version', 'workspace', 'sandbox', 'skills', 'mcp', 'model',
34
45
  ]);
35
46
  const ALLOWED_SANDBOX_MODES = new Set([
36
47
  'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
@@ -112,6 +123,18 @@ export const validateEnvironmentSpec = (spec) => {
112
123
  errors.push(`version must be 1, got ${JSON.stringify(spec.version)}`);
113
124
  }
114
125
 
126
+ // Validated as an opaque non-empty string, deliberately not against a list of
127
+ // known model ids. A whitelist here would need editing every time a provider
128
+ // ships a model, and would reject a valid id the local CLI understands and
129
+ // this file does not — failing the user's attach for a fact it is not the
130
+ // authority on. The adapter passes it through to `--model`; the CLI is the
131
+ // thing that knows what is valid, and its error is the honest one.
132
+ if (spec.model !== undefined) {
133
+ if (typeof spec.model !== 'string' || spec.model.trim() === '') {
134
+ errors.push('model must be a non-empty string');
135
+ }
136
+ }
137
+
115
138
  if (spec.workspace !== undefined) {
116
139
  if (typeof spec.workspace !== 'object' || spec.workspace === null) {
117
140
  errors.push('workspace must be an object');
@@ -19,11 +19,37 @@ export const SPAWN_CIRCUIT_THRESHOLD = 3;
19
19
  export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
20
20
  export const SPAWN_RETRY_JITTER_MAX_RATIO = 0.2;
21
21
 
22
- // `out of credits` is codex's exact wording for an exhausted workspace balance
23
- // ("Your workspace is out of credits. Ask your workspace owner to refill…").
24
- // Without it that outage classified as RUNTIME and drew the shortest backoff —
25
- // observed live on 2026-08-03 before this pattern was added.
26
- const QUOTA_RE = /(?:quota|usage limit|credit balance|out of credits|billing|insufficient[_ -]?quota|resource exhausted|spending limit)/i;
22
+ // This list is a per-provider allowlist, and it only ever grows after an
23
+ // outage has already been misclassified. Twice now:
24
+ // 2026-08-03 codex "Your workspace is out of credits." → `out of credits`
25
+ // 2026-08-18 claude "You've hit your session limit" → `session limit`
26
+ // The second one is the instructive failure: `usage limit` was already here
27
+ // it is Claude's OTHER exhaustion wording — so the fleet stalled for an hour on
28
+ // a string one word away from a pattern we had. Both times the miss meant
29
+ // RUNTIME, the weakest class with the shortest backoff, against a provider that
30
+ // was not going to answer for hours.
31
+ //
32
+ // What a miss costs, measured against these constants (intervalMs 5000,
33
+ // jitter 0; `*` = circuit open):
34
+ //
35
+ // quota n=1,2,3 -> 900s* 900s* 900s*
36
+ // configuration n=1,2,3 -> 900s* 900s* 900s*
37
+ // rate_limit n=1,2,3 -> 60s* 120s* 240s*
38
+ // runtime n=1,2,3 -> 5s 10s 60s*
39
+ //
40
+ // So an unmatched wording is not one class off — it is 180x faster on the
41
+ // first retry than the class it belonged in, and the only class that does not
42
+ // open the circuit at n=1. That is the price of a missing string, and it is
43
+ // why the entry below is a list of exact wordings rather than a loose pattern.
44
+ //
45
+ // Note RUNTIME is also `classifySpawnFailure`'s fallthrough, so "unrecognised"
46
+ // and "transient local fault" resolve to the same, most aggressive schedule.
47
+ // That is a structural issue rather than a vocabulary one — see #996.
48
+ //
49
+ // Deliberately NOT loosened to a bare `limit`: QUOTA is tested before
50
+ // RATE_LIMIT, so that would swallow every "rate limit" error into the 15-minute
51
+ // cooldown. Add exact wordings, not looser ones.
52
+ const QUOTA_RE = /(?:quota|usage limit|session limit|credit balance|out of credits|billing|insufficient[_ -]?quota|resource exhausted|spending limit)/i;
27
53
  const RATE_LIMIT_RE = /(?:rate[ -]?limit|too many requests|\b429\b|overloaded|capacity)/i;
28
54
  const CONFIGURATION_RE = /(?:ENOENT|command not found|not on PATH|login required|not logged in|invalid api key|authentication failed|unauthori[sz]ed|forbidden|\b40[13]\b)/i;
29
55