@commonlyai/cli 0.1.11 → 0.1.13

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.11",
3
+ "version": "0.1.13",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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
 
@@ -839,13 +861,55 @@ export const performRun = ({
839
861
  if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
840
862
  const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
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.
@@ -64,6 +64,99 @@ export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention',
64
64
 
65
65
  // ── cascade governor ────────────────────────────────────────────────────────
66
66
 
67
+ export const CASCADE_DEFAULTS = Object.freeze({
68
+ cap: 3,
69
+ addressedGrace: 2,
70
+ resetMs: 10 * 60 * 1000,
71
+ });
72
+
73
+ // Env names, exported so the CLI help text and the tests quote the same
74
+ // strings rather than two copies that can drift apart.
75
+ export const CASCADE_ENV_VARS = Object.freeze({
76
+ cap: 'COMMONLY_CASCADE_CAP',
77
+ addressedGrace: 'COMMONLY_CASCADE_ADDRESSED_GRACE',
78
+ resetMs: 'COMMONLY_CASCADE_RESET_MS',
79
+ });
80
+
81
+ // Flag spelling per key, for warning messages. A derived string
82
+ // (key.replace(...)) would silently produce a flag that does not exist the
83
+ // first time a key is renamed; this fails at the same moment the flag does.
84
+ const CASCADE_FLAGS = {
85
+ cap: '--cascade-cap',
86
+ addressedGrace: '--cascade-grace',
87
+ resetMs: '--cascade-reset',
88
+ };
89
+
90
+ const CASCADE_BOUNDS = {
91
+ // 0 is meaningful, not a mistake: it refuses every agent-triggered turn in
92
+ // the pod, which is the "this room is on fire, mute the cascades" setting.
93
+ cap: { min: 0, max: 1000, integer: true },
94
+ // 0 restores the pre-#973 behaviour exactly. That is the point of the knob —
95
+ // the grace can be taken back on one seat, in one restart, without a revert
96
+ // and without a source edit.
97
+ addressedGrace: { min: 0, max: 1000, integer: true },
98
+ resetMs: { min: 1000, max: 24 * 60 * 60 * 1000, integer: true },
99
+ };
100
+
101
+ /**
102
+ * Resolve the three governor constants from (in precedence order) an explicit
103
+ * override, an environment variable, then the shipped default.
104
+ *
105
+ * These were literals in `run()`'s signature, reachable only by editing source
106
+ * — on a fleet whose CLI is a symlink into a live worktree, so "retune the
107
+ * cap" meant an edit plus a restart of every seat, with nothing recording
108
+ * which value ran. The sibling dampener one service over
109
+ * (AGENT_ASK_RATE_LIMIT_PER_HOUR) has always been env-tunable; this is
110
+ * copying that, not inventing it.
111
+ *
112
+ * An unparseable or out-of-range value warns and falls back rather than
113
+ * throwing. A seat that crash-loops on a typo'd env var is a worse outcome
114
+ * than one running the default — but a silent fallback would hide the typo,
115
+ * so it is loud.
116
+ *
117
+ * Overrides are validated on the SAME path as env values, deliberately. An
118
+ * earlier draft trusted them and passed them straight through, which made
119
+ * `--cascade-cap abc` resolve to NaN — and `streak < NaN` is false for every
120
+ * streak, so a typo at the command line would have silently refused every
121
+ * agent-triggered turn the seat ever saw. The louder source is not the safer
122
+ * one; there is one validator because there is one way to be wrong.
123
+ */
124
+ export const resolveCascadeSettings = ({
125
+ env = process.env,
126
+ overrides = {},
127
+ warn = (msg) => console.warn(msg),
128
+ } = {}) => {
129
+ const resolved = {};
130
+ for (const key of Object.keys(CASCADE_DEFAULTS)) {
131
+ const bounds = CASCADE_BOUNDS[key];
132
+ const fallback = CASCADE_DEFAULTS[key];
133
+ const override = overrides[key];
134
+ const hasOverride = override !== undefined && override !== null;
135
+ const raw = hasOverride ? override : env?.[CASCADE_ENV_VARS[key]];
136
+ // Names the source in the warning, so "which one did I get wrong" is
137
+ // answered by the message rather than by bisecting the launch command.
138
+ const label = hasOverride ? CASCADE_FLAGS[key] : CASCADE_ENV_VARS[key];
139
+
140
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
141
+ resolved[key] = fallback;
142
+ continue;
143
+ }
144
+ const parsed = Number(raw);
145
+ if (!Number.isFinite(parsed) || (bounds.integer && !Number.isInteger(parsed))) {
146
+ warn(`${label}='${raw}' is not an integer — using ${fallback}`);
147
+ resolved[key] = fallback;
148
+ continue;
149
+ }
150
+ if (parsed < bounds.min || parsed > bounds.max) {
151
+ warn(`${label}=${parsed} is outside [${bounds.min}, ${bounds.max}] — using ${fallback}`);
152
+ resolved[key] = fallback;
153
+ continue;
154
+ }
155
+ resolved[key] = parsed;
156
+ }
157
+ return resolved;
158
+ };
159
+
67
160
  /**
68
161
  * Per-pod damping of agent→agent retrigger chains.
69
162
  *
@@ -71,23 +164,42 @@ export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention',
71
164
  * agent's replies) until the operator killed the wrapper at round 3. The
72
165
  * governor counts CONSECUTIVE agent-triggered turns per pod; at `cap` it
73
166
  * 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).
167
+ * the streak, or `resetMs` passes with no ADMITTED agent-triggered turn from
168
+ * this seat in this pod (so a damped seat recovers on its own — a legitimate
169
+ * a2a handoff an hour later must not inherit a stale cap).
170
+ *
171
+ * NOTE on `resetMs`: it is a silence window, but the silence is far narrower
172
+ * than "the pod is quiet", and it is measured on two axes people get wrong in
173
+ * opposite directions:
174
+ *
175
+ * WHOSE turns — `pods` is a Map in this closure, inside ONE wrapper
176
+ * process. It never observes another seat's turns at all.
177
+ * Three chatty peers cannot keep this streak alive.
178
+ * WHICH turns — only turns that reached `record()`. A refusal returns from
179
+ * agent.js before it (`:859` vs `:1087`), so a capped seat
180
+ * stops updating `lastAgentTurnAt` entirely and its clock
181
+ * runs free from the last turn it was ALLOWED.
182
+ *
183
+ * Net shape is a token bucket, not a static ceiling: a burst of `cap`, then
184
+ * `cap` admits per `resetMs`, per pod. Sustained agent traffic with zero human
185
+ * turns yields 6/9/12/15/18/18/18/18 admits per hour as arrivals go from one
186
+ * per 600s to one per second — inert when quiet, pinned at cap-per-window
187
+ * however hard the burst gets. Per POD: a seat in N pods holds N buckets, so
188
+ * its seat-level ceiling is N times that.
77
189
  *
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.
190
+ * An earlier version of this note said the window requires zero agent turns
191
+ * "in the pod", and concluded that in a busy room the only live release is a
192
+ * human turn. Both halves are wrong, and wrongly reassuring: a capped seat
193
+ * self-releases every window no matter how loud the room is.
82
194
  *
83
195
  * Split into admit/record so a spawn that fails (and will be redelivered)
84
196
  * never double-counts: admit() only reads, record() runs after a turn
85
197
  * actually completed. Human-triggered turns are always admitted.
86
198
  */
87
199
  export const createCascadeGovernor = ({
88
- cap = 3,
89
- addressedGrace = 2,
90
- resetMs = 10 * 60 * 1000,
200
+ cap = CASCADE_DEFAULTS.cap,
201
+ addressedGrace = CASCADE_DEFAULTS.addressedGrace,
202
+ resetMs = CASCADE_DEFAULTS.resetMs,
91
203
  now = Date.now,
92
204
  } = {}) => {
93
205
  const pods = new Map(); // podId -> { streak, lastAgentTurnAt }
@@ -117,7 +229,18 @@ export const createCascadeGovernor = ({
117
229
  // cap + addressedGrace instead of never.
118
230
  const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
119
231
  const limit = addressed ? cap + addressedGrace : cap;
120
- return { allowed: s.streak < limit, streak: s.streak, addressed };
232
+ // `addressed` describes the EVENT; `graceApplied` describes what this
233
+ // governor actually did with it. They diverge whenever addressedGrace is
234
+ // 0 — the pre-#973 setting, and the first thing an operator dials back —
235
+ // where an addressed event is refused at the plain cap having been given
236
+ // nothing extra. Reporting only `addressed` made the refusal log claim a
237
+ // grace was "also spent" on a seat whose own boot line says grace=0.
238
+ return {
239
+ allowed: s.streak < limit,
240
+ streak: s.streak,
241
+ addressed,
242
+ graceApplied: addressed && addressedGrace > 0,
243
+ };
121
244
  },
122
245
  record(podId, trigger) {
123
246
  if (trigger === 'human') {