@commonlyai/cli 0.1.65 → 0.1.67

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.65",
3
+ "version": "0.1.67",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -42,6 +42,7 @@ import {
42
42
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
43
43
  import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
44
44
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
45
+ import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
45
46
  import {
46
47
  DEFAULT_HOOK_TIMEOUT_MS,
47
48
  clampHookTimeoutMs,
@@ -1249,13 +1250,22 @@ export const performRun = ({
1249
1250
  const heartbeatControlReply = (event.type === 'heartbeat' || event.payload?.hasHeartbeat === true)
1250
1251
  && /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
1251
1252
  const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
1253
+ // A refused model route is not a silent turn. pi reports the refusal on its
1254
+ // own JSON stream and exits 0 with no text, so before this the seat log read
1255
+ // `no wrapper-post (empty output)` and the cause — a 429 budget message — was
1256
+ // nowhere (TASK-096; two and a half days of this on one seat).
1257
+ const upstream = result.upstream || null;
1252
1258
  let delivered = agentPostedItself;
1253
1259
  let deliveryRefusal = null;
1254
1260
 
1255
1261
  if (event.type === 'agent.ask') {
1256
1262
  if (silentReply) {
1257
1263
  const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
1258
- log(`[${event.type}] no private response (${reason})`);
1264
+ // The same naming rule as the chat path below: an ask that went
1265
+ // unanswered because the route refused must not read as the agent
1266
+ // having nothing to say.
1267
+ if (upstream) log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — no private response`);
1268
+ else log(`[${event.type}] no private response (${reason})`);
1259
1269
  } else {
1260
1270
  try {
1261
1271
  await client.post(
@@ -1304,6 +1314,11 @@ export const performRun = ({
1304
1314
  + `(matched message ${suppressedBy.id} by ${suppressedBy.author} `
1305
1315
  + `via ${suppressedBy.basis})`,
1306
1316
  );
1317
+ } else if (upstream) {
1318
+ // The status is always named; the body only when the keep-list kept one.
1319
+ // No retry is added: the adapter's own ladder has already run, and a
1320
+ // refused route is refused on the next attempt too.
1321
+ log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — nothing posted this turn`);
1307
1322
  } else {
1308
1323
  log(`[${event.type}] no wrapper-post (${reason}) — nothing posted this turn`);
1309
1324
  }
@@ -1394,6 +1409,21 @@ export const performRun = ({
1394
1409
  },
1395
1410
  };
1396
1411
  }
1412
+ // A refusal is not a decline. `no_action` with no reason means "the agent
1413
+ // chose not to answer" and hands a human wake to one remaining listener;
1414
+ // nothing about the agent's choice happened here — the model never ran — so
1415
+ // say what did happen, and let the event close rather than fan a
1416
+ // route-wide refusal out across the rest of the fleet.
1417
+ if (!delivered && upstream) {
1418
+ return {
1419
+ outcome: 'no_action',
1420
+ reason: `upstream-refused-${upstream.status}`,
1421
+ details: {
1422
+ status: upstream.status,
1423
+ ...(upstream.detail ? { detail: upstream.detail } : {}),
1424
+ },
1425
+ };
1426
+ }
1397
1427
  return { outcome: delivered ? 'posted' : 'no_action' };
1398
1428
  };
1399
1429
 
@@ -38,6 +38,10 @@ import { mkdir, readdir, writeFile } from 'fs/promises';
38
38
  import { homedir } from 'os';
39
39
  import { dirname, join } from 'path';
40
40
  import { fileURLToPath } from 'url';
41
+ import {
42
+ readUpstreamRefusal,
43
+ spawnCredentials,
44
+ } from '../upstream-refusal.js';
41
45
  import { buildMemoryPreamble } from '../memory-bridge.js';
42
46
  import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
43
47
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
@@ -326,7 +330,7 @@ export const extractReply = (stdout) => {
326
330
  return { text, sawAssistant, errors };
327
331
  };
328
332
 
329
- const runPi = ({ args, cwd, env, payload, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
333
+ const runPi = ({ args, cwd, env, payload, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
330
334
  let stdout = '';
331
335
  let stderr = '';
332
336
  let timedOut = false;
@@ -352,6 +356,11 @@ const runPi = ({ args, cwd, env, payload, timeoutMs, spawnImpl = childSpawn }) =
352
356
  clearTimeout(timer);
353
357
  if (timedOut) return reject(new Error(`pi timed out after ${timeoutMs}ms`));
354
358
  const reply = extractReply(stdout);
359
+ // A refused model route is NOT an error event: pi retries the ladder itself
360
+ // and exits 0 with no assistant text, which is why this read has to happen
361
+ // on the stream rather than on the exit code (TASK-096, measured 2026-09-22).
362
+ const upstream = readUpstreamRefusal(stdout, { credentials });
363
+ if (upstream) reply.upstream = upstream;
355
364
  if (code !== 0 && !reply.sawAssistant) {
356
365
  const tail = (reply.errors.join(' | ') || stderr).trim().slice(-600);
357
366
  return reject(new Error(`pi exited ${code}: ${tail}`));
@@ -383,6 +392,12 @@ export default {
383
392
  if (!baseEnv[provider.apiKeyEnv]) {
384
393
  throw new Error(`pi adapter: ${provider.apiKeyEnv} is not set — the seat's environment must carry the provider key (LiteLLM virtual key)`);
385
394
  }
395
+ // The values this spawn is handed, for the refusal log's exact-match check:
396
+ // the provider key the route authenticates with, and the seat credential the
397
+ // bridge carries. Snapshot them HERE — `withholdRuntimeCredential` removes
398
+ // the token from the child copy below, and after that there is nothing left
399
+ // to compare a refusal against.
400
+ const credentials = spawnCredentials(ctx, [provider.apiKeyEnv]);
386
401
 
387
402
  // Per-seat pi home: models.json holds the provider by env reference.
388
403
  const home = seatHome(ctx);
@@ -437,11 +452,13 @@ export default {
437
452
  env: childEnv,
438
453
  payload: servers.length ? JSON.stringify(servers) : undefined,
439
454
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
455
+ credentials,
440
456
  spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
441
457
  });
442
458
  // Empty text with a clean exit is a silent turn; the run loop treats it
443
- // as NO_REPLY-shaped and re-delivers on its own rules.
444
- return { text: reply.text, newSessionId: sessionId };
459
+ // as NO_REPLY-shaped and re-delivers on its own rules. Empty text that
460
+ // came with a refusal is named instead — see upstream-refusal.js.
461
+ return { text: reply.text, newSessionId: sessionId, upstream: reply.upstream ?? null };
445
462
  } finally {
446
463
  // Best effort: the bridge has already read what it needs by the time the
447
464
  // turn ends, and a token file that outlives its turn is a token file that
@@ -0,0 +1,205 @@
1
+ /**
2
+ * TASK-096 — name a refused model route, instead of reporting the silence it
3
+ * causes.
4
+ *
5
+ * Measured 2026-09-22 against pi 0.84.1 with a route answering 429 (the shape
6
+ * that made a seat look broken for two and a half days): pi runs its own retry
7
+ * ladder, reports the refusal on `message_end`/`auto_retry_end` JSON events, and
8
+ * EXITS 0 with no assistant text. `extractReply` in the pi adapter collected only
9
+ * `type: "error"` events — of which this produces none — so the wrapper logged
10
+ * `no wrapper-post (empty output)` and the cause sat unread in the stream.
11
+ *
12
+ * Two rules govern the text that reaches a seat log:
13
+ *
14
+ * 1. KEEP-LIST, not strip-list (Vera 70876, Wren 70877). A strip-list removes
15
+ * only the shapes we thought of, and a 401 can echo the key back. So a body
16
+ * contributes one NAMED field or nothing at all.
17
+ * 2. Exact-match refusal for credentials. If the kept text contains a value
18
+ * this spawn was handed, the text is dropped — a known value, not a guessed
19
+ * shape (Wren 70877).
20
+ *
21
+ * Two limits follow from rule 2. They are the accepted cost of refusing guessed
22
+ * shapes, named here so they are not rediscovered as defects later (Vera 71082):
23
+ *
24
+ * a. The match is exact and against THIS spawn's values only, so a credential
25
+ * that arrives transformed — base64 inside a proxy error, percent-encoded in
26
+ * a URL echo — does not match and the text is kept. Matching transformed
27
+ * shapes instead would reintroduce the class rule 1 exists to avoid.
28
+ * b. The keep-list can still surface someone ELSE's secret. A third party's key
29
+ * relayed in an upstream error is not detectable here, because the one
30
+ * inventory this module has is the set of values it was itself handed.
31
+ *
32
+ * Scope: this reader is wired into the pi adapter only. codex and claude are NOT
33
+ * silent the way pi was: the claude adapter rejects on a non-zero exit and
34
+ * deliberately reports stdout with it, because a `-p` run writes quota
35
+ * conditions there; the codex adapter rejects on `turnFailedMessage` even at
36
+ * exit 0. Their gap is the opposite one — the text they raise is the raw tail,
37
+ * unscrubbed by the rules above, and carries no HTTP status for the run loop to
38
+ * key a refusal on. Extending this module to them is adapter work (TASK-103),
39
+ * not a line in this comment.
40
+ */
41
+
42
+ /** The kept detail is truncated, ellipsis included, to this many characters. */
43
+ export const MAX_REFUSAL_DETAIL = 256;
44
+
45
+ /**
46
+ * The status an adapter prefixes to its error text: `"429: {…}"` -> `429`.
47
+ * Null for anything that is not a 4xx/5xx, so a message that merely starts with
48
+ * a number ("4040 tokens over budget") is not read as a status.
49
+ *
50
+ * The separator pi uses is NOT fixed — measured 2026-09-22, the same pi 0.84.1
51
+ * emits `"429: {"message":…}"` for a JSON body and `"502 <html>…"` for an
52
+ * HTML one. Both forms are matched here; a parser that required the colon would
53
+ * have kept `502 <html>…` as the whole "body" and then dropped it as non-JSON,
54
+ * reporting the status alone with no sign that the body was never read.
55
+ */
56
+ const STATUS_PREFIX = /^\s*(\d{3})(?!\d)\s*:?\s*/;
57
+
58
+ export const statusFromError = (value) => {
59
+ const match = STATUS_PREFIX.exec(String(value ?? ''));
60
+ if (!match) return null;
61
+ const status = Number(match[1]);
62
+ return status >= 400 && status <= 599 ? status : null;
63
+ };
64
+
65
+ /**
66
+ * The body the adapter carried after its status prefix, or the whole text when
67
+ * there is no status to strip. `"429: {"message":"a: b"}"` -> `{"message":"a: b"}`;
68
+ * `"502 <html>"` -> `<html>`.
69
+ */
70
+ export const bodyFromError = (value) => {
71
+ const text = String(value ?? '');
72
+ if (statusFromError(text) === null) return text.trim();
73
+ return text.replace(STATUS_PREFIX, '').trim();
74
+ };
75
+
76
+ /**
77
+ * Replace credential values this spawn was handed with a marker.
78
+ *
79
+ * This is the exact-match half, for text that is kept for its whole shape rather
80
+ * than filtered to one field — an adapter's stderr tail, say, whose diagnostic
81
+ * value is in the lines around the secret. Values shorter than eight characters
82
+ * are ignored: redacting a one-character "credential" would corrupt ordinary
83
+ * prose, and no issued credential is that short.
84
+ */
85
+ export const redactKnownCredentials = (text, credentials = []) => {
86
+ let out = String(text ?? '');
87
+ for (const value of credentials) {
88
+ if (typeof value !== 'string' || value.length < 8) continue;
89
+ out = out.split(value).join('[redacted]');
90
+ }
91
+ return out;
92
+ };
93
+
94
+ /**
95
+ * The keep-list. Given the raw body an adapter captured, keep at most one named
96
+ * field of it and only when that field is a string:
97
+ *
98
+ * `error.message` first (the OpenAI-shaped error), then a top-level `message`
99
+ * (which is the shape LiteLLM's budget refusal actually uses — the motivating
100
+ * case; a keep-list that kept only `error.message` would name the status and
101
+ * still hide the budget line that started this row).
102
+ *
103
+ * Everything else — HTML, a non-JSON body, a JSON body with neither field, a
104
+ * body echoing a known credential — contributes nothing. Returns null rather
105
+ * than a partial guess.
106
+ */
107
+ export const keptRefusalDetail = (raw, { credentials = [] } = {}) => {
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(String(raw ?? ''));
111
+ } catch {
112
+ return null;
113
+ }
114
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
115
+
116
+ const candidate = parsed?.error?.message ?? parsed?.message;
117
+ if (typeof candidate !== 'string') return null;
118
+
119
+ const text = candidate.replace(/\s+/g, ' ').trim();
120
+ if (!text) return null;
121
+
122
+ const redacted = redactKnownCredentials(text, credentials);
123
+ // `[redacted]` appearing is the signal that the body echoed something this
124
+ // spawn was handed. The status is still worth keeping; the text is not.
125
+ if (redacted !== text) return null;
126
+
127
+ return text.length > MAX_REFUSAL_DETAIL
128
+ ? `${text.slice(0, MAX_REFUSAL_DETAIL - 1)}…`
129
+ : text;
130
+ };
131
+
132
+ /**
133
+ * Read a pi JSON stream and return the refusal it ended on, or null.
134
+ *
135
+ * `auto_retry_end` is the terminal word — it names the status and the body after
136
+ * the ladder has finished — so it wins over the per-attempt `message_end`
137
+ * events that precede it. A run that refused on its first attempt and never
138
+ * retried still reports through `message_end`.
139
+ */
140
+ export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => {
141
+ let status = null;
142
+ let raw = null;
143
+
144
+ for (const line of String(stdout ?? '').split('\n')) {
145
+ if (!line.trim()) continue;
146
+ let event;
147
+ try {
148
+ event = JSON.parse(line);
149
+ } catch {
150
+ continue;
151
+ }
152
+
153
+ if (event?.type === 'auto_retry_end' && event.success === false) {
154
+ const candidate = event.finalError ?? event.errorMessage;
155
+ const attemptStatus = statusFromError(candidate);
156
+ if (attemptStatus) {
157
+ status = attemptStatus;
158
+ raw = bodyFromError(candidate);
159
+ }
160
+ continue;
161
+ }
162
+
163
+ const message = event?.message;
164
+ if (
165
+ event?.type === 'message_end'
166
+ && message?.role === 'assistant'
167
+ && message.stopReason === 'error'
168
+ && status === null
169
+ ) {
170
+ const attemptStatus = statusFromError(message.errorMessage);
171
+ if (attemptStatus) {
172
+ status = attemptStatus;
173
+ raw = bodyFromError(message.errorMessage);
174
+ }
175
+ }
176
+ }
177
+
178
+ if (status === null) return null;
179
+ return { status, detail: keptRefusalDetail(raw, { credentials }) };
180
+ };
181
+
182
+ /**
183
+ * The seat-log clause. The status is always present; the detail only when the
184
+ * keep-list kept one. `grep 'upstream refused'` finds every refusal, which is
185
+ * the property the old `no wrapper-post (empty output)` line did not have.
186
+ */
187
+ export const describeUpstreamRefusal = (refusal) => {
188
+ if (!refusal || typeof refusal.status !== 'number') return null;
189
+ return refusal.detail
190
+ ? `upstream refused ${refusal.status}: ${refusal.detail}`
191
+ : `upstream refused ${refusal.status}`;
192
+ };
193
+
194
+ /**
195
+ * The credential values this spawn was handed, for the exact-match check in
196
+ * `redactKnownCredentials`. `envNames` are the adapter's own provider keys; the
197
+ * caller supplies them because only the adapter knows which key its route uses.
198
+ */
199
+ export const spawnCredentials = (ctx = {}, envNames = []) => {
200
+ const env = ctx.env || process.env || {};
201
+ return [
202
+ ctx.runtimeToken,
203
+ ...envNames.map((name) => env[name]),
204
+ ].filter((value) => typeof value === 'string' && value.length >= 8);
205
+ };