@commonlyai/cli 0.1.10 → 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.10",
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
 
@@ -803,15 +825,91 @@ export const performRun = ({
803
825
  // capped agent-triggered event is exactly the traffic we want dropped.
804
826
  // Human-triggered turns are never capped.
805
827
  const trigger = classifyTrigger(event, preSpawn);
828
+ // A human turn is a property of the POD, not of this seat's reaction to
829
+ // it. Record it as soon as it is classified — before the cascade check and
830
+ // before the claim race — so a seat that stands down still observes the
831
+ // reset.
832
+ //
833
+ // Without this, losing a claim on a human message is SELF-REINFORCING.
834
+ // The claim stand-down returns before `runTurn`, and `record()` lives at
835
+ // the end of `runTurn`, so the loser never records the human turn that
836
+ // would have cleared its streak. It meets the next broadcast still capped,
837
+ // declines, and again skips `record()`. The only other escape is the
838
+ // seat's own resetMs clock: stateFor reads only this process's
839
+ // lastAgentTurnAt, so a capped seat self-clears 10 minutes after its last
840
+ // completed agent turn regardless of room traffic — cyclical throttling,
841
+ // not permanent starvation.
842
+ //
843
+ // Measured in one pod, 2026-08-18, same window: ux-lead 14 lost claims /
844
+ // 73 cap refusals / 2 posts, against sprint-review 0 / 2 / 95. Losing one
845
+ // race is what kept it losing — the mechanism meant to SHARE work
846
+ // concentrated it instead.
847
+ //
848
+ // `=== 'human'` rather than `!== 'agent'`. These are behaviourally the
849
+ // same today: `record()` dispatches on exact equality and leaves every
850
+ // other value untouched, including the 'unknown' that `classifyTrigger`
851
+ // returns for an unresolvable trigger — `enforcement.js:123-131`, and the
852
+ // "'unknown' is neutral: no count, no reset" note at :130. The narrow form
853
+ // is defensive, not load-bearing: it keeps an unidentifiable event from
854
+ // becoming a cap-reset primitive if `record()` ever grows a fallthrough
855
+ // branch. It closes no hole that was open.
856
+ //
857
+ // Agent-triggered turns still record only on completion (see `runTurn`),
858
+ // so the no-double-count property for redelivered events is unchanged.
859
+ // Recording a human trigger sets the streak to 0, so this call and the
860
+ // completion-time one are idempotent with each other.
861
+ if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
806
862
  const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
807
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.
808
870
  log(
809
871
  `[${event.type}] cascade cap: ${admission.streak} consecutive agent-triggered `
810
872
  + `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',
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',
813
883
  );
814
- 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
+ };
815
913
  }
816
914
 
817
915
  // ── ADR-018 enforcement: claim-before-act ───────────────────────────────
@@ -987,7 +1085,34 @@ export const performRun = ({
987
1085
  }
988
1086
  } else if (silentReply) {
989
1087
  const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
990
- log(`[${event.type}] no wrapper-post (${reason})`);
1088
+ // A turn that posted via tool and THEN ended with the sentinel is not the
1089
+ // same event as a turn that produced nothing — but this branch reported
1090
+ // both as `no wrapper-post (NO_REPLY)`, because it is evaluated before
1091
+ // the `agentPostedItself` branch below and swallowed that fact.
1092
+ //
1093
+ // The skill told agents to do exactly this (post via commonly_post_message,
1094
+ // then end with NO_REPLY so the wrapper does not double-post), so CORRECT
1095
+ // behaviour and total silence were indistinguishable on stdout.
1096
+ //
1097
+ // Cost, 2026-08-18: a seat was diagnosed as mute for "19 hours" on the
1098
+ // strength of `grep -c "posted via tool" == 0` against its log. It had in
1099
+ // fact posted seven times in that window. Five remedies were applied to a
1100
+ // seat that was never broken — cleared session, fresh process, MCP
1101
+ // repointed, model repinned — before the seat itself pointed at the
1102
+ // ledger. The instrument was broken, not the agent.
1103
+ //
1104
+ // Report the two cases distinctly. `posted` is the load-bearing word: a
1105
+ // reader scanning for whether a seat is contributing must not have to
1106
+ // infer it from the absence of a different line.
1107
+ if (agentPostedItself) {
1108
+ log(
1109
+ `[${event.type}] posted via tool, then ${reason} — no echo needed `
1110
+ + `(matched message ${suppressedBy.id} by ${suppressedBy.author} `
1111
+ + `via ${suppressedBy.basis})`,
1112
+ );
1113
+ } else {
1114
+ log(`[${event.type}] no wrapper-post (${reason}) — nothing posted this turn`);
1115
+ }
991
1116
  } else if (agentPostedItself) {
992
1117
  // Name the message that caused the suppression. A silently dropped reply
993
1118
  // is invisible to everyone; #757 went unnoticed precisely because this
@@ -1343,6 +1468,23 @@ export const performDetach = async ({
1343
1468
  */
1344
1469
  const stamp = () => new Date().toISOString();
1345
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
+
1346
1488
  export const registerAgent = (program) => {
1347
1489
  const agent = program.command('agent').description('Manage agents');
1348
1490
 
@@ -1683,6 +1825,9 @@ Docs:
1683
1825
  .description('Run the local-CLI wrapper loop for an attached agent')
1684
1826
  .option('--interval <ms>', 'Poll interval in ms', '5000')
1685
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})`)
1686
1831
  .action(async (name, opts) => {
1687
1832
  let record = loadAgentToken(name);
1688
1833
  if (!record) {
@@ -1736,6 +1881,7 @@ Docs:
1736
1881
  environment: record.environment || null,
1737
1882
  workspacePath: record.workspacePath || null,
1738
1883
  intervalMs: parseInt(opts.interval, 10),
1884
+ ...cascadeOverridesFromOpts(opts),
1739
1885
  log: (line) => console.log(`${stamp()} [${name}] ${line}`),
1740
1886
  // Both sinks are stamped, and both must be — but not for the reason
1741
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,24 +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
- * 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.
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.
83
194
  *
84
195
  * Split into admit/record so a spawn that fails (and will be redelivered)
85
196
  * never double-counts: admit() only reads, record() runs after a turn
86
197
  * actually completed. Human-triggered turns are always admitted.
87
198
  */
88
199
  export const createCascadeGovernor = ({
89
- cap = 3,
90
- addressedGrace = 2,
91
- resetMs = 10 * 60 * 1000,
200
+ cap = CASCADE_DEFAULTS.cap,
201
+ addressedGrace = CASCADE_DEFAULTS.addressedGrace,
202
+ resetMs = CASCADE_DEFAULTS.resetMs,
92
203
  now = Date.now,
93
204
  } = {}) => {
94
205
  const pods = new Map(); // podId -> { streak, lastAgentTurnAt }
@@ -118,7 +229,18 @@ export const createCascadeGovernor = ({
118
229
  // cap + addressedGrace instead of never.
119
230
  const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
120
231
  const limit = addressed ? cap + addressedGrace : cap;
121
- 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
+ };
122
244
  },
123
245
  record(podId, trigger) {
124
246
  if (trigger === 'human') {