@commonlyai/cli 0.1.11 → 0.1.15

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.11",
3
+ "version": "0.1.15",
4
4
  "license": "Apache-2.0",
5
- "description": "The Commonly CLI connect agents, manage pods, iterate fast",
5
+ "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
8
8
  "bin": {
@@ -41,6 +41,8 @@ import {
41
41
  } from '../lib/spawn-retry.js';
42
42
  import {
43
43
  ADDRESSED_EVENT_TYPES,
44
+ CASCADE_DEFAULTS,
45
+ CASCADE_ENV_VARS,
44
46
  CLAIMABLE_EVENT_TYPES,
45
47
  classifyTrigger,
46
48
  createCascadeGovernor,
@@ -48,6 +50,7 @@ import {
48
50
  createClaimKeeper,
49
51
  deliverChatReply,
50
52
  peerHoldsFrame,
53
+ resolveCascadeSettings,
51
54
  } from '../lib/enforcement.js';
52
55
 
53
56
  // ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
@@ -709,9 +712,12 @@ export const performRun = ({
709
712
  clearIntervalImpl = clearInterval,
710
713
  retryJitterRatio,
711
714
  claimLeaseSeconds = 90,
712
- cascadeCap = 3,
713
- cascadeAddressedGrace = 2,
714
- cascadeResetMs = 10 * 60 * 1000,
715
+ // Undefined means "not overridden" — resolveCascadeSettings falls back to
716
+ // the env var, then to the shipped default. Passing a value here still
717
+ // wins, which is how the tests pin a cap of 1.
718
+ cascadeCap,
719
+ cascadeAddressedGrace,
720
+ cascadeResetMs,
715
721
  chatCharLimit = 400,
716
722
  maxChatChunks = 3,
717
723
  claimYieldDelayMs = 3000,
@@ -730,11 +736,27 @@ export const performRun = ({
730
736
  const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
731
737
  // Per-seat cascade state — lives with the process, like the session store.
732
738
  // A wrapper restart forgets the streak; the decay window covers that gap.
733
- const cascadeGovernor = createCascadeGovernor({
734
- cap: cascadeCap,
735
- addressedGrace: cascadeAddressedGrace,
736
- resetMs: cascadeResetMs,
739
+ const cascadeSettings = resolveCascadeSettings({
740
+ overrides: {
741
+ cap: cascadeCap,
742
+ addressedGrace: cascadeAddressedGrace,
743
+ resetMs: cascadeResetMs,
744
+ },
745
+ warn: (message) => log(`cascade config: ${message}`),
737
746
  });
747
+ const cascadeGovernor = createCascadeGovernor(cascadeSettings);
748
+ // Unconditional, because the only other cascade line on this path is the
749
+ // warn callback above — which fires only when a value is BAD. Without this,
750
+ // a seat running COMMONLY_CASCADE_CAP=8 logs exactly what a seat on the
751
+ // shipped default logs, and an env var (unlike the source edit it replaces)
752
+ // leaves no git evidence of which seat diverged. The `(defaults)` marker
753
+ // makes "show me every retuned seat" a grep for its absence.
754
+ const cascadeIsDefault = Object.keys(CASCADE_DEFAULTS)
755
+ .every((key) => cascadeSettings[key] === CASCADE_DEFAULTS[key]);
756
+ log(
757
+ `cascade: cap=${cascadeSettings.cap} grace=${cascadeSettings.addressedGrace} `
758
+ + `reset=${cascadeSettings.resetMs}ms${cascadeIsDefault ? ' (defaults)' : ''}`,
759
+ );
738
760
  // Fairness: recent broadcast-race winners start the next race from the back.
739
761
  const claimHandicap = createClaimHandicap({ delayMs: claimYieldDelayMs });
740
762
 
@@ -837,15 +859,57 @@ export const performRun = ({
837
859
  // Recording a human trigger sets the streak to 0, so this call and the
838
860
  // completion-time one are idempotent with each other.
839
861
  if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
840
- const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
862
+ const admission = cascadeGovernor.admit(eventPodId, trigger, event.type, event.payload);
841
863
  if (!admission.allowed) {
864
+ // Name the MESSAGE, not just the pod. Without this the refusal is silent at
865
+ // three ends, not two: the mentioning agent gets no signal its mention died,
866
+ // an operator reading the log cannot tell which mentions were dropped, and
867
+ // the suppressed seat cannot say what it ignored — it never saw the id. Two
868
+ // real mentions were lost this way on 2026-08-19 and the seat they were
869
+ // addressed to could not enumerate them afterwards.
842
870
  log(
843
871
  `[${event.type}] cascade cap: ${admission.streak} consecutive agent-triggered `
844
872
  + `turns in pod ${eventPodId}`
845
- + (admission.addressed ? ' (addressed grace also spent)' : '')
846
- + ' standing down until a human speaks or the pod goes quiet for the reset window',
873
+ + (event?.payload?.messageId ? ` (dropped message ${event.payload.messageId})` : '')
874
+ + (admission.graceApplied ? ' (addressed grace also spent)' : '')
875
+ // NOT "until the pod goes quiet": other seats' traffic never touches
876
+ // this seat's clock, and a refusal returns here without reaching
877
+ // `record()` below — so the window is measured from this seat's last
878
+ // ADMITTED turn in this pod and runs regardless of how loud the room
879
+ // is. Saying "the pod goes quiet" told an operator to wait for a lull
880
+ // that is neither necessary nor sufficient (#989).
881
+ + ` — standing down until a human speaks or ${cascadeSettings.resetMs}ms `
882
+ + 'passes with no admitted turn from this seat in this pod',
847
883
  );
848
- return { outcome: 'no_action', reason: 'cascade-cap' };
884
+ // `details` rides through to the AgentEvent row untouched:
885
+ // normalizeDeliveryMeta whitelists outcome/reason/messageId and passes
886
+ // `details` straight into `delivery.details`, typed Schema.Types.Mixed.
887
+ // So the settings that produced this refusal become queryable with NO
888
+ // backend change and no deploy.
889
+ //
890
+ // This is the half the boot log cannot cover. The log says what a seat
891
+ // resolved; it does not survive a restart, and nothing joins it to the
892
+ // refusals it caused. Once cap is per-seat and arbitrary, a refusal
893
+ // whose cap is unknown cannot be compared against one from another seat.
894
+ // `reason` deliberately stays the fixed literal 'cascade-cap' — :1111
895
+ // matches it with === and the run-loop tests assert it — so the value
896
+ // goes in a sibling field instead of being spelled into the reason.
897
+ return {
898
+ outcome: 'no_action',
899
+ reason: 'cascade-cap',
900
+ details: {
901
+ // The id of what was dropped. `reason` and `streak` say a refusal
902
+ // happened; only this says WHICH message never got answered, which is
903
+ // what anyone auditing a missed mention actually needs to join on.
904
+ messageId: event?.payload?.messageId || null,
905
+ streak: admission.streak,
906
+ cap: cascadeSettings.cap,
907
+ addressedGrace: cascadeSettings.addressedGrace,
908
+ resetMs: cascadeSettings.resetMs,
909
+ addressed: admission.addressed,
910
+ graceApplied: admission.graceApplied,
911
+ },
912
+ };
849
913
  }
850
914
 
851
915
  // ── ADR-018 enforcement: claim-before-act ───────────────────────────────
@@ -1404,6 +1468,23 @@ export const performDetach = async ({
1404
1468
  */
1405
1469
  const stamp = () => new Date().toISOString();
1406
1470
 
1471
+ /**
1472
+ * Map `agent run`'s cascade flags onto resolveCascadeSettings' override keys.
1473
+ *
1474
+ * Exported because the mapping is where the flag path can go wrong invisibly:
1475
+ * an earlier version coerced with Number() here, so `--cascade-cap abc`
1476
+ * reached the resolver as NaN and the warning read `--cascade-cap='NaN'` —
1477
+ * naming a value the user never typed, on the one line whose whole job is to
1478
+ * tell them which input was bad. The values pass through RAW; the resolver is
1479
+ * the only thing that parses, for the same reason it is the only thing that
1480
+ * validates.
1481
+ */
1482
+ export const cascadeOverridesFromOpts = (opts = {}) => ({
1483
+ cascadeCap: opts.cascadeCap,
1484
+ cascadeAddressedGrace: opts.cascadeGrace,
1485
+ cascadeResetMs: opts.cascadeReset,
1486
+ });
1487
+
1407
1488
  export const registerAgent = (program) => {
1408
1489
  const agent = program.command('agent').description('Manage agents');
1409
1490
 
@@ -1744,6 +1825,9 @@ Docs:
1744
1825
  .description('Run the local-CLI wrapper loop for an attached agent')
1745
1826
  .option('--interval <ms>', 'Poll interval in ms', '5000')
1746
1827
  .option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex); ignored when a token file already exists')
1828
+ .option('--cascade-cap <n>', `Consecutive agent-triggered turns allowed per pod (env ${CASCADE_ENV_VARS.cap}, default ${CASCADE_DEFAULTS.cap})`)
1829
+ .option('--cascade-grace <n>', `Extra turns allowed when this seat was directly addressed; 0 disables the grace (env ${CASCADE_ENV_VARS.addressedGrace}, default ${CASCADE_DEFAULTS.addressedGrace})`)
1830
+ .option('--cascade-reset <ms>', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`)
1747
1831
  .action(async (name, opts) => {
1748
1832
  let record = loadAgentToken(name);
1749
1833
  if (!record) {
@@ -1797,6 +1881,7 @@ Docs:
1797
1881
  environment: record.environment || null,
1798
1882
  workspacePath: record.workspacePath || null,
1799
1883
  intervalMs: parseInt(opts.interval, 10),
1884
+ ...cascadeOverridesFromOpts(opts),
1800
1885
  log: (line) => console.log(`${stamp()} [${name}] ${line}`),
1801
1886
  // Both sinks are stamped, and both must be — but not for the reason
1802
1887
  // this comment gave until now, which its own PR falsified.
@@ -45,6 +45,14 @@ export const CLAIMABLE_EVENT_TYPES = new Set([
45
45
  */
46
46
  export const classifyTrigger = (event, recentMessages) => {
47
47
  const p = event?.payload || {};
48
+ // The third pricing branch (#1044, fable 55845). Kernel-found work is not
49
+ // agent churn and must not price as it: counted as 'agent' it eats cascade
50
+ // budget exactly when the board is busiest; counted as 'human' it CLEARS the
51
+ // brake on unrelated cascades. 'kernel' is neutral like 'unknown' — no
52
+ // count, no reset — but chosen on purpose rather than fallen into, and the
53
+ // refusal/boot logs can say so. Checked before dmKind: triggerAuthor is the
54
+ // honest field (#1018) and wins where both appear.
55
+ if (p.triggerAuthor === 'kernel') return 'kernel';
48
56
  if (p.dmKind === 'agent-agent') return 'agent';
49
57
  if (p.dmKind === 'user-agent') return 'human';
50
58
  if (!p.messageId || !Array.isArray(recentMessages)) return 'unknown';
@@ -62,8 +70,114 @@ export const classifyTrigger = (event, recentMessages) => {
62
70
  // race is a free stand-down.
63
71
  export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
64
72
 
73
+ // These are the two event types the kernel's bot-to-bot mention dampener
74
+ // bounds as one shared budget. Keep the wrapper out of that same loop's
75
+ // admission path: named seats need to respond even when broadcasts have
76
+ // exhausted their cascade budget. The backend cannot be a runtime import of
77
+ // the published CLI, so cli/__tests__/mention-event-types.contract.test.mjs
78
+ // imports both modules and pins the two lists together.
79
+ //
80
+ // Agent-DM wakes also arrive as chat.mention, but carry payload.dmKind. They
81
+ // stay on this governor's bounded path: event type alone does not identify the
82
+ // producer that owns the kernel mention budget. dm.message is legacy consumer
83
+ // vocabulary and remains on that same ordinary addressed-grace path.
84
+ export const MENTION_EVENT_TYPES = new Set(['chat.mention', 'thread.mention']);
85
+
65
86
  // ── cascade governor ────────────────────────────────────────────────────────
66
87
 
88
+ export const CASCADE_DEFAULTS = Object.freeze({
89
+ cap: 3,
90
+ addressedGrace: 2,
91
+ resetMs: 10 * 60 * 1000,
92
+ });
93
+
94
+ // Env names, exported so the CLI help text and the tests quote the same
95
+ // strings rather than two copies that can drift apart.
96
+ export const CASCADE_ENV_VARS = Object.freeze({
97
+ cap: 'COMMONLY_CASCADE_CAP',
98
+ addressedGrace: 'COMMONLY_CASCADE_ADDRESSED_GRACE',
99
+ resetMs: 'COMMONLY_CASCADE_RESET_MS',
100
+ });
101
+
102
+ // Flag spelling per key, for warning messages. A derived string
103
+ // (key.replace(...)) would silently produce a flag that does not exist the
104
+ // first time a key is renamed; this fails at the same moment the flag does.
105
+ const CASCADE_FLAGS = {
106
+ cap: '--cascade-cap',
107
+ addressedGrace: '--cascade-grace',
108
+ resetMs: '--cascade-reset',
109
+ };
110
+
111
+ const CASCADE_BOUNDS = {
112
+ // 0 is meaningful, not a mistake: it refuses every agent-triggered turn in
113
+ // the pod, which is the "this room is on fire, mute the cascades" setting.
114
+ cap: { min: 0, max: 1000, integer: true },
115
+ // 0 restores the pre-#973 behaviour exactly. That is the point of the knob —
116
+ // the grace can be taken back on one seat, in one restart, without a revert
117
+ // and without a source edit.
118
+ addressedGrace: { min: 0, max: 1000, integer: true },
119
+ resetMs: { min: 1000, max: 24 * 60 * 60 * 1000, integer: true },
120
+ };
121
+
122
+ /**
123
+ * Resolve the three governor constants from (in precedence order) an explicit
124
+ * override, an environment variable, then the shipped default.
125
+ *
126
+ * These were literals in `run()`'s signature, reachable only by editing source
127
+ * — on a fleet whose CLI is a symlink into a live worktree, so "retune the
128
+ * cap" meant an edit plus a restart of every seat, with nothing recording
129
+ * which value ran. The sibling dampener one service over
130
+ * (AGENT_ASK_RATE_LIMIT_PER_HOUR) has always been env-tunable; this is
131
+ * copying that, not inventing it.
132
+ *
133
+ * An unparseable or out-of-range value warns and falls back rather than
134
+ * throwing. A seat that crash-loops on a typo'd env var is a worse outcome
135
+ * than one running the default — but a silent fallback would hide the typo,
136
+ * so it is loud.
137
+ *
138
+ * Overrides are validated on the SAME path as env values, deliberately. An
139
+ * earlier draft trusted them and passed them straight through, which made
140
+ * `--cascade-cap abc` resolve to NaN — and `streak < NaN` is false for every
141
+ * streak, so a typo at the command line would have silently refused every
142
+ * agent-triggered turn the seat ever saw. The louder source is not the safer
143
+ * one; there is one validator because there is one way to be wrong.
144
+ */
145
+ export const resolveCascadeSettings = ({
146
+ env = process.env,
147
+ overrides = {},
148
+ warn = (msg) => console.warn(msg),
149
+ } = {}) => {
150
+ const resolved = {};
151
+ for (const key of Object.keys(CASCADE_DEFAULTS)) {
152
+ const bounds = CASCADE_BOUNDS[key];
153
+ const fallback = CASCADE_DEFAULTS[key];
154
+ const override = overrides[key];
155
+ const hasOverride = override !== undefined && override !== null;
156
+ const raw = hasOverride ? override : env?.[CASCADE_ENV_VARS[key]];
157
+ // Names the source in the warning, so "which one did I get wrong" is
158
+ // answered by the message rather than by bisecting the launch command.
159
+ const label = hasOverride ? CASCADE_FLAGS[key] : CASCADE_ENV_VARS[key];
160
+
161
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
162
+ resolved[key] = fallback;
163
+ continue;
164
+ }
165
+ const parsed = Number(raw);
166
+ if (!Number.isFinite(parsed) || (bounds.integer && !Number.isInteger(parsed))) {
167
+ warn(`${label}='${raw}' is not an integer — using ${fallback}`);
168
+ resolved[key] = fallback;
169
+ continue;
170
+ }
171
+ if (parsed < bounds.min || parsed > bounds.max) {
172
+ warn(`${label}=${parsed} is outside [${bounds.min}, ${bounds.max}] — using ${fallback}`);
173
+ resolved[key] = fallback;
174
+ continue;
175
+ }
176
+ resolved[key] = parsed;
177
+ }
178
+ return resolved;
179
+ };
180
+
67
181
  /**
68
182
  * Per-pod damping of agent→agent retrigger chains.
69
183
  *
@@ -71,23 +185,42 @@ export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention',
71
185
  * agent's replies) until the operator killed the wrapper at round 3. The
72
186
  * governor counts CONSECUTIVE agent-triggered turns per pod; at `cap` it
73
187
  * refuses further agent-triggered turns until a human-triggered turn resets
74
- * the streak or `resetMs` passes with no agent-triggered turn (so a damped
75
- * pod recovers on its own — a legitimate a2a handoff an hour later must not
76
- * inherit a stale cap).
188
+ * the streak, or `resetMs` passes with no ADMITTED agent-triggered turn from
189
+ * this seat in this pod (so a damped seat recovers on its own — a legitimate
190
+ * a2a handoff an hour later must not inherit a stale cap).
191
+ *
192
+ * NOTE on `resetMs`: it is a silence window, but the silence is far narrower
193
+ * than "the pod is quiet", and it is measured on two axes people get wrong in
194
+ * opposite directions:
195
+ *
196
+ * WHOSE turns — `pods` is a Map in this closure, inside ONE wrapper
197
+ * process. It never observes another seat's turns at all.
198
+ * Three chatty peers cannot keep this streak alive.
199
+ * WHICH turns — only turns that reached `record()`. A refusal returns from
200
+ * agent.js before it (`:859` vs `:1087`), so a capped seat
201
+ * stops updating `lastAgentTurnAt` entirely and its clock
202
+ * runs free from the last turn it was ALLOWED.
203
+ *
204
+ * Net shape is a token bucket, not a static ceiling: a burst of `cap`, then
205
+ * `cap` admits per `resetMs`, per pod. Sustained agent traffic with zero human
206
+ * turns yields 6/9/12/15/18/18/18/18 admits per hour as arrivals go from one
207
+ * per 600s to one per second — inert when quiet, pinned at cap-per-window
208
+ * however hard the burst gets. Per POD: a seat in N pods holds N buckets, so
209
+ * its seat-level ceiling is N times that.
77
210
  *
78
- * The only other escape is the seat's own resetMs clock: stateFor reads only
79
- * this process's lastAgentTurnAt, so a capped seat self-clears 10
80
- * minutes after its last completed agent turn regardless of room traffic
81
- * cyclical throttling, not permanent starvation.
211
+ * An earlier version of this note said the window requires zero agent turns
212
+ * "in the pod", and concluded that in a busy room the only live release is a
213
+ * human turn. Both halves are wrong, and wrongly reassuring: a capped seat
214
+ * self-releases every window no matter how loud the room is.
82
215
  *
83
216
  * Split into admit/record so a spawn that fails (and will be redelivered)
84
217
  * never double-counts: admit() only reads, record() runs after a turn
85
218
  * actually completed. Human-triggered turns are always admitted.
86
219
  */
87
220
  export const createCascadeGovernor = ({
88
- cap = 3,
89
- addressedGrace = 2,
90
- resetMs = 10 * 60 * 1000,
221
+ cap = CASCADE_DEFAULTS.cap,
222
+ addressedGrace = CASCADE_DEFAULTS.addressedGrace,
223
+ resetMs = CASCADE_DEFAULTS.resetMs,
91
224
  now = Date.now,
92
225
  } = {}) => {
93
226
  const pods = new Map(); // podId -> { streak, lastAgentTurnAt }
@@ -101,9 +234,24 @@ export const createCascadeGovernor = ({
101
234
  };
102
235
 
103
236
  return {
104
- admit(podId, trigger, eventType) {
237
+ admit(podId, trigger, eventType, payload = {}) {
105
238
  if (trigger !== 'agent') return { allowed: true, streak: 0, addressed: false };
106
239
  const s = stateFor(podId);
240
+ const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
241
+ if (MENTION_EVENT_TYPES.has(eventType) && !payload?.dmKind) {
242
+ // Named mentions are bounded upstream by the kernel's shared
243
+ // bot-to-bot mention dampener. A DM emits chat.mention too, but its
244
+ // dmKind identifies a different producer, so it remains locally
245
+ // bounded. Every admitted turn still reaches record() after it
246
+ // completes, consuming broadcast liveness rather than creating an
247
+ // unmetered second loop.
248
+ return {
249
+ allowed: true,
250
+ streak: s.streak,
251
+ addressed,
252
+ graceApplied: false,
253
+ };
254
+ }
107
255
  // Being NAMED outranks a mechanical brake — the same judgement the claim
108
256
  // path already makes forty lines down in agent.js. Without this, a peer
109
257
  // can @mention a capped seat and get silence, with no signal to either
@@ -111,13 +259,22 @@ export const createCascadeGovernor = ({
111
259
  // 51 wakes and 28 consecutive cap refusals, five of them chat.mention,
112
260
  // and answered none of them.
113
261
  //
114
- // A GRACE, not an exemption: an unbounded pass would restore the exact
115
- // A-mentions-B-mentions-A echo this governor exists to kill. Addressed
116
- // turns still count toward the streak, so a mention loop terminates at
117
- // cap + addressedGrace instead of never.
118
- const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
262
+ // Legacy direct-address events retain a GRACE, not an exemption. They
263
+ // still count toward the streak, so they terminate at cap +
264
+ // addressedGrace instead of never.
119
265
  const limit = addressed ? cap + addressedGrace : cap;
120
- return { allowed: s.streak < limit, streak: s.streak, addressed };
266
+ // `addressed` describes the EVENT; `graceApplied` describes what this
267
+ // governor actually did with it. They diverge whenever addressedGrace is
268
+ // 0 — the pre-#973 setting, and the first thing an operator dials back —
269
+ // where an addressed event is refused at the plain cap having been given
270
+ // nothing extra. Reporting only `addressed` made the refusal log claim a
271
+ // grace was "also spent" on a seat whose own boot line says grace=0.
272
+ return {
273
+ allowed: s.streak < limit,
274
+ streak: s.streak,
275
+ addressed,
276
+ graceApplied: addressed && addressedGrace > 0,
277
+ };
121
278
  },
122
279
  record(podId, trigger) {
123
280
  if (trigger === 'human') {
@@ -126,7 +283,8 @@ export const createCascadeGovernor = ({
126
283
  const s = stateFor(podId);
127
284
  pods.set(podId, { streak: s.streak + 1, lastAgentTurnAt: now() });
128
285
  }
129
- // 'unknown' is neutral: no count, no reset.
286
+ // 'unknown' is neutral by FALLBACK; 'kernel' is neutral by DESIGN —
287
+ // both take this path, but only one of them is an accident.
130
288
  },
131
289
  };
132
290
  };