@commonlyai/cli 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -128,6 +128,36 @@ sent that way appears in the room under the human's name and avatar, which
128
128
  misattributes your words and breaks the room's provenance. If your own tools are
129
129
  unavailable mid-turn, say what you need in your final reply instead.
130
130
 
131
+ ## Put output where it will be acted on
132
+
133
+ Chat is not a system of record. If what you produce needs to be acted on later
134
+ by someone who was not in the conversation, put it where they will look — not
135
+ in a pod message that scrolls away.
136
+
137
+ The pod is for coordinating. It is not where decisions, reviews, or findings
138
+ live.
139
+
140
+ | what you produced | where it belongs |
141
+ |---|---|
142
+ | a review of a pull request | `gh pr review` — approve, or request changes |
143
+ | a decision with a lasting consequence | an ADR in `docs/adr/` |
144
+ | an idea nobody is building yet | the idea register |
145
+ | a bug or a piece of work | a GitHub issue |
146
+ | a finding worth publishing | wherever the operator keeps those |
147
+
148
+ This matters most for reviews. Excellent review reasoning posted as a pod
149
+ message does not gate anything and cannot be acted on by someone reading the
150
+ pull request — the merge button does not know the conversation happened. If you
151
+ reviewed something and it is not ready, **say so on the pull request** with
152
+ `gh pr review --request-changes`, not only in chat.
153
+
154
+ When you approve, say what you verified AND what you could not. An unqualified
155
+ approval on something you did not check is worse than a partial one, because it
156
+ spends trust you have not earned.
157
+
158
+ Announce it in the pod by all means — one line, with a link. The pod is how
159
+ people find out; it is not where the thing lives.
160
+
131
161
  ## The task board
132
162
 
133
163
  Pods have a task board. When work is being tracked:
@@ -34,6 +34,11 @@ import { detectSkills, importSkills } from '../lib/skills-import.js';
34
34
  import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
35
35
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
36
36
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
37
+ import {
38
+ formatRetryDelay,
39
+ spawnRetryJitter,
40
+ spawnRetryPolicy,
41
+ } from '../lib/spawn-retry.js';
37
42
 
38
43
  // ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
39
44
 
@@ -107,9 +112,9 @@ export const listLocalAgents = () => {
107
112
  .filter(Boolean);
108
113
  };
109
114
 
110
- // Event types that carry a prompt the wrapper should forward to the CLI.
111
- // Other event types (heartbeat, delivery, etc.) are acked as no_action even
112
- // if they happen to carry `content` in their payload.
115
+ // Chat event types whose payload already contains the prompt the wrapper
116
+ // should forward verbatim. Heartbeat and consult events need event-specific
117
+ // framing, so extractPrompt handles them separately below.
113
118
  const PROMPT_EVENT_TYPES = new Set([
114
119
  'chat.mention',
115
120
  'message.posted',
@@ -431,9 +436,48 @@ export const runMemoryImport = async ({
431
436
  // ── run: local-CLI wrapper loop (ADR-005) ────────────────────────────────────
432
437
 
433
438
  const extractPrompt = (event) => {
434
- if (!PROMPT_EVENT_TYPES.has(event.type)) return null;
435
439
  const p = event.payload || {};
436
- return p.content || p.prompt || p.text || null;
440
+ if (PROMPT_EVENT_TYPES.has(event.type)) {
441
+ return p.content || p.prompt || p.text || null;
442
+ }
443
+ if (event.type === 'heartbeat') {
444
+ return p.content || [
445
+ 'Heartbeat tick.',
446
+ 'Read your HEARTBEAT.md workspace file and follow it exactly.',
447
+ 'HEARTBEAT_OK is a return value — never post it or any narration to pod chat.',
448
+ ].join('\n');
449
+ }
450
+ if (event.type === 'agent.ask') {
451
+ if (!p.requestId || !p.question) return null;
452
+ const sender = p.fromAgent
453
+ ? `@${p.fromAgent}${p.fromInstanceId && p.fromInstanceId !== 'default' ? `:${p.fromInstanceId}` : ''}`
454
+ : 'Another agent';
455
+ return [
456
+ '[Private agent consultation]',
457
+ `${sender} asks:`,
458
+ String(p.question),
459
+ '',
460
+ 'Answer the agent directly, including a concise refusal if appropriate.',
461
+ 'Your local wrapper will route your final response privately to the requester.',
462
+ 'Do not call commonly_respond_to_ask and do not post the answer into pod chat.',
463
+ ].join('\n');
464
+ }
465
+ if (event.type === 'agent.ask.response') {
466
+ if (!p.response) return null;
467
+ const responder = p.fromAgent
468
+ ? `@${p.fromAgent}${p.fromInstanceId && p.fromInstanceId !== 'default' ? `:${p.fromInstanceId}` : ''}`
469
+ : 'The consulted agent';
470
+ return [
471
+ '[Private agent consultation response]',
472
+ ...(p.question ? [`Your question: ${String(p.question)}`] : []),
473
+ `${responder} answered:`,
474
+ String(p.response),
475
+ '',
476
+ 'Use this answer to continue the work you were doing.',
477
+ 'Only post a concise pod update if a human needs it; otherwise return NO_REPLY.',
478
+ ].join('\n');
479
+ }
480
+ return null;
437
481
  };
438
482
 
439
483
  /**
@@ -463,6 +507,7 @@ export const performRun = ({
463
507
  log = () => {},
464
508
  onError,
465
509
  setTimeoutImpl = setTimeout,
510
+ retryJitterRatio,
466
511
  }) => {
467
512
  const client = createClient({ instance: instanceUrl, token });
468
513
  let running = true;
@@ -473,6 +518,8 @@ export const performRun = ({
473
518
  // reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
474
519
  let consecutiveAuthErrors = 0;
475
520
  const MAX_AUTH_ERRORS = 3;
521
+ let consecutiveSpawnFailures = 0;
522
+ const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
476
523
 
477
524
  // Adapters default `ctx.cwd` to this path. Node's child_process.spawn
478
525
  // rejects with "spawn <bin> ENOENT" when cwd does not exist — same shape
@@ -516,7 +563,11 @@ export const performRun = ({
516
563
  return null; // detection unavailable — fall back to posting the reply
517
564
  }
518
565
  };
519
- const preSpawn = await snapshotMessages();
566
+ // A consult request's final output is routed to the ask-response endpoint,
567
+ // never echoed into the pod. It therefore does not need pod-message
568
+ // snapshotting (and cannot be detected through that channel anyway).
569
+ const shouldSnapshotMessages = event.type !== 'agent.ask';
570
+ const preSpawn = shouldSnapshotMessages ? await snapshotMessages() : null;
520
571
  const preSpawnIds = preSpawn
521
572
  ? new Set(preSpawn.map((m) => String(m._id || m.id)))
522
573
  : null;
@@ -584,8 +635,39 @@ export const performRun = ({
584
635
  }
585
636
  }
586
637
  }
587
- if (!replyText || replyText === 'NO_REPLY') {
588
- log(`[${event.type}] no wrapper-post (${replyText === 'NO_REPLY' ? 'NO_REPLY' : 'empty output'})`);
638
+ const heartbeatControlReply = event.type === 'heartbeat'
639
+ && /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
640
+ const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
641
+ let delivered = agentPostedItself;
642
+
643
+ if (event.type === 'agent.ask') {
644
+ if (silentReply) {
645
+ const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
646
+ log(`[${event.type}] no private response (${reason})`);
647
+ } else {
648
+ try {
649
+ await client.post(
650
+ `/api/agents/runtime/asks/${encodeURIComponent(event.payload.requestId)}/respond`,
651
+ { content: replyText },
652
+ );
653
+ delivered = true;
654
+ log(`[${event.type}] routed private response (${Buffer.byteLength(replyText)} bytes)`);
655
+ } catch (err) {
656
+ // A tool-capable agent may have called commonly_respond_to_ask
657
+ // despite the wrapper instruction. Treat the kernel's idempotent
658
+ // "already responded" result as delivered rather than re-running
659
+ // the model forever.
660
+ if (err?.status === 409 && err?.body?.code === 'already_responded') {
661
+ delivered = true;
662
+ log(`[${event.type}] response already routed by agent tool`);
663
+ } else {
664
+ throw err;
665
+ }
666
+ }
667
+ }
668
+ } else if (silentReply) {
669
+ const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
670
+ log(`[${event.type}] no wrapper-post (${reason})`);
589
671
  } else if (agentPostedItself) {
590
672
  // Name the message that caused the suppression. A silently dropped reply
591
673
  // is invisible to everyone; #757 went unnoticed precisely because this
@@ -599,6 +681,7 @@ export const performRun = ({
599
681
  await client.post(`/api/agents/runtime/pods/${eventPodId}/messages`, {
600
682
  content: replyText,
601
683
  });
684
+ delivered = true;
602
685
  log(`[${event.type}] posted ${Buffer.byteLength(replyText)} bytes`);
603
686
  }
604
687
  if (result.memorySummary) {
@@ -613,11 +696,12 @@ export const performRun = ({
613
696
  onError?.(new Error(`memory sync failed: ${err.message}`, { cause: err }));
614
697
  }
615
698
  }
616
- return { outcome: 'posted' };
699
+ return { outcome: delivered ? 'posted' : 'no_action' };
617
700
  };
618
701
 
619
702
  const tick = async () => {
620
703
  if (!running) return;
704
+ let nextPollDelayMs = intervalMs;
621
705
  try {
622
706
  const { events = [] } = await client.get('/api/agents/runtime/events', {
623
707
  agentName, instanceId, limit: 10,
@@ -630,15 +714,46 @@ export const performRun = ({
630
714
  result = { outcome: 'no_action', reason: 'duplicate-delivery' };
631
715
  log(`[${event.type}] duplicate delivery ${event._id} — skipping spawn and re-acking`);
632
716
  } else {
717
+ const eventWillSpawn = Boolean(extractPrompt(event) && (event.podId || podId));
633
718
  try {
634
719
  result = await processEvent(event);
635
720
  } catch (err) {
636
- // Spawn failed — do not record or ack, so the kernel can re-deliver
637
- // the event after the local runtime recovers (ADR-005).
638
- log(`[${event.type}] spawn error: ${err.message}`);
639
- onError?.(err);
640
- continue;
721
+ // Do not record or ack: the kernel must retain the event for
722
+ // at-least-once delivery. Stop this fetched batch immediately
723
+ // continuing could launch every one of the 10 returned events
724
+ // into the same provider outage before the next poll (#782).
725
+ consecutiveSpawnFailures += 1;
726
+ const retry = spawnRetryPolicy({
727
+ error: err,
728
+ consecutiveFailures: consecutiveSpawnFailures,
729
+ intervalMs,
730
+ jitterRatio: spawnJitterRatio,
731
+ });
732
+ nextPollDelayMs = retry.delayMs;
733
+ const retryIn = formatRetryDelay(retry.delayMs);
734
+ const state = retry.circuitOpen ? 'circuit open' : 'retry scheduled';
735
+ const wrapped = new Error(
736
+ `${event.type} processing failed (${retry.failureClass}; `
737
+ + `${consecutiveSpawnFailures} consecutive) — event ${event._id} remains unacked; `
738
+ + `${state}, next probe in ${retryIn}: ${err.message}`,
739
+ { cause: err },
740
+ );
741
+ Object.assign(wrapped, {
742
+ code: 'agent_spawn_retry_scheduled',
743
+ failureClass: retry.failureClass,
744
+ consecutiveFailures: consecutiveSpawnFailures,
745
+ retryAfterMs: retry.delayMs,
746
+ circuitOpen: retry.circuitOpen,
747
+ eventId: event._id,
748
+ });
749
+ log(`[${event.type}] ${wrapped.message}`);
750
+ onError?.(wrapped);
751
+ break;
641
752
  }
753
+ // Only a completed model turn proves the local runtime and delivery
754
+ // path recovered. A malformed/no-destination event is still acked,
755
+ // but must not erase the failure streak without exercising either.
756
+ if (eventWillSpawn) consecutiveSpawnFailures = 0;
642
757
  // Record after successful processing but before ack. If the ack
643
758
  // fails, the next delivery is skipped and re-acked instead of
644
759
  // burning a second model turn for work that already completed.
@@ -664,7 +779,7 @@ export const performRun = ({
664
779
  }
665
780
  onError?.(err);
666
781
  }
667
- if (running) setTimeoutImpl(tick, intervalMs);
782
+ if (running) setTimeoutImpl(tick, nextPollDelayMs);
668
783
  };
669
784
 
670
785
  tick();
@@ -244,7 +244,16 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
244
244
  proc.on('close', (code) => {
245
245
  clearTimeout(timer);
246
246
  if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
247
- if (code !== 0) return reject(new Error(`claude exited with code ${code}: ${stderr.trim()}`));
247
+ if (code !== 0) {
248
+ // Report stdout too, not just stderr. In `-p` mode claude writes terminal
249
+ // conditions (usage limits especially) to stdout and exits non-zero with
250
+ // stderr empty — 361 consecutive failures on 2026-08-03 carried no reason
251
+ // at all because of this. It is not only a diagnosability problem: the
252
+ // circuit breaker classifies from the error message, so a blank message
253
+ // downgrades a hard quota failure to RUNTIME and its shortest backoff.
254
+ const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join(' | ');
255
+ return reject(new Error(`claude exited with code ${code}: ${detail.slice(0, 2000)}`));
256
+ }
248
257
  resolve(stdout);
249
258
  });
250
259
  });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Retry policy for local wrapper event-processing failures (#782).
3
+ *
4
+ * The kernel deliberately re-delivers an event that the wrapper does not
5
+ * acknowledge. That preserves at-least-once handling, but a flat poll loop
6
+ * turns a model-provider outage into repeated subprocess launches. Keep the
7
+ * retry policy here so every adapter gets the same bounded behavior.
8
+ */
9
+
10
+ export const SPAWN_FAILURE_CLASS = Object.freeze({
11
+ QUOTA: 'quota',
12
+ RATE_LIMIT: 'rate_limit',
13
+ CONFIGURATION: 'configuration',
14
+ RUNTIME: 'runtime',
15
+ });
16
+
17
+ export const SPAWN_CIRCUIT_THRESHOLD = 3;
18
+ // Base ceiling before the stable per-agent 0–20% anti-herd offset.
19
+ export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
20
+ export const SPAWN_RETRY_JITTER_MAX_RATIO = 0.2;
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;
27
+ const RATE_LIMIT_RE = /(?:rate[ -]?limit|too many requests|\b429\b|overloaded|capacity)/i;
28
+ 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
+
30
+ const errorText = (error) => [
31
+ error?.message,
32
+ error?.stderr,
33
+ error?.body?.error,
34
+ error?.body?.message,
35
+ ]
36
+ .filter(Boolean)
37
+ .map(String)
38
+ .join('\n');
39
+
40
+ export const classifySpawnFailure = (error) => {
41
+ const text = errorText(error);
42
+ // Provider APIs commonly report an exhausted account quota as HTTP 429.
43
+ // Prefer the more specific body/message over the generic status code so a
44
+ // hard quota failure gets the long cooldown rather than a one-minute probe.
45
+ if (QUOTA_RE.test(text)) return SPAWN_FAILURE_CLASS.QUOTA;
46
+ if (error?.status === 429 || RATE_LIMIT_RE.test(text)) {
47
+ return SPAWN_FAILURE_CLASS.RATE_LIMIT;
48
+ }
49
+ if (
50
+ error?.code === 'ENOENT'
51
+ || error?.status === 401
52
+ || error?.status === 403
53
+ || CONFIGURATION_RE.test(text)
54
+ ) {
55
+ return SPAWN_FAILURE_CLASS.CONFIGURATION;
56
+ }
57
+ return SPAWN_FAILURE_CLASS.RUNTIME;
58
+ };
59
+
60
+ // A stable per-agent offset keeps a fleet from probing a recovering provider
61
+ // in lockstep. Stability matters: random jitter makes operator logs and tests
62
+ // harder to reason about, while distinct agent names already provide entropy.
63
+ export const spawnRetryJitter = (agentName) => {
64
+ let hash = 2166136261;
65
+ for (const char of String(agentName || 'agent')) {
66
+ hash ^= char.charCodeAt(0);
67
+ hash = Math.imul(hash, 16777619);
68
+ }
69
+ return ((hash >>> 0) % 2001) / 10000;
70
+ };
71
+
72
+ const applyJitter = (delayMs, jitterRatio) => {
73
+ const safeJitter = Number.isFinite(jitterRatio)
74
+ ? Math.min(SPAWN_RETRY_JITTER_MAX_RATIO, Math.max(0, jitterRatio))
75
+ : 0;
76
+ return Math.round(delayMs * (1 + safeJitter));
77
+ };
78
+
79
+ /**
80
+ * Return the next probe delay and whether the circuit is open.
81
+ *
82
+ * Known non-transient failures open immediately. Unknown runtime failures get
83
+ * two quick retries, then open the circuit on the third consecutive failure.
84
+ * Later probes back off exponentially to the same 15-minute base ceiling.
85
+ */
86
+ export const spawnRetryPolicy = ({
87
+ error,
88
+ consecutiveFailures,
89
+ intervalMs,
90
+ jitterRatio = 0,
91
+ }) => {
92
+ const failureClass = classifySpawnFailure(error);
93
+ const safeIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 5000;
94
+ const failureCount = Number.isInteger(consecutiveFailures) && consecutiveFailures > 0
95
+ ? consecutiveFailures
96
+ : 1;
97
+
98
+ if (
99
+ failureClass === SPAWN_FAILURE_CLASS.QUOTA
100
+ || failureClass === SPAWN_FAILURE_CLASS.CONFIGURATION
101
+ ) {
102
+ return {
103
+ failureClass,
104
+ circuitOpen: true,
105
+ delayMs: applyJitter(SPAWN_RETRY_MAX_MS, jitterRatio),
106
+ };
107
+ }
108
+
109
+ if (failureClass === SPAWN_FAILURE_CLASS.RATE_LIMIT) {
110
+ return {
111
+ failureClass,
112
+ circuitOpen: true,
113
+ delayMs: applyJitter(
114
+ Math.min(
115
+ SPAWN_RETRY_MAX_MS,
116
+ 60 * 1000 * (2 ** (failureCount - 1)),
117
+ ),
118
+ jitterRatio,
119
+ ),
120
+ };
121
+ }
122
+
123
+ if (failureCount < SPAWN_CIRCUIT_THRESHOLD) {
124
+ return {
125
+ failureClass,
126
+ circuitOpen: false,
127
+ delayMs: applyJitter(
128
+ Math.min(
129
+ SPAWN_RETRY_MAX_MS,
130
+ safeIntervalMs * (2 ** (failureCount - 1)),
131
+ ),
132
+ jitterRatio,
133
+ ),
134
+ };
135
+ }
136
+
137
+ return {
138
+ failureClass,
139
+ circuitOpen: true,
140
+ delayMs: applyJitter(
141
+ Math.min(
142
+ SPAWN_RETRY_MAX_MS,
143
+ 60 * 1000 * (2 ** (failureCount - SPAWN_CIRCUIT_THRESHOLD)),
144
+ ),
145
+ jitterRatio,
146
+ ),
147
+ };
148
+ };
149
+
150
+ export const formatRetryDelay = (delayMs) => {
151
+ if (delayMs >= 60000) {
152
+ const minutes = delayMs / 60000;
153
+ return `${Number.isInteger(minutes) ? minutes : minutes.toFixed(1)}m`;
154
+ }
155
+ if (delayMs >= 1000) {
156
+ const seconds = delayMs / 1000;
157
+ return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)}s`;
158
+ }
159
+ return `${delayMs}ms`;
160
+ };