@commonlyai/cli 0.1.220 → 0.1.222

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.220",
3
+ "version": "0.1.222",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -188,22 +188,58 @@ export const runInboxAck = async (itemId, opts = {}, deps = {}) => {
188
188
  }
189
189
  };
190
190
 
191
+ // `value` is either a 1-based option index (position in the decision's options,
192
+ // shown on refusal) or an option label. The server stores whatever string it is sent, so
193
+ // the label is resolved here against the open queue and only the label is posted:
194
+ // a bare "2" recorded in the ruling would mean nothing to anyone reading it later.
195
+ export const resolveDecisionChoice = (options, value) => {
196
+ const choices = (Array.isArray(options) ? options : [])
197
+ .map((option) => String(option?.label ?? '').trim())
198
+ .filter(Boolean);
199
+ const listing = choices.map((label, i) => ` ${i + 1}. ${label}`).join('\n');
200
+ const raw = String(value).trim();
201
+ if (/^\d+$/.test(raw)) {
202
+ const index = Number(raw);
203
+ const labelled = choices.findIndex((label) => label === raw);
204
+ // A digit-only label is also a position in the list. When the two point at
205
+ // different options, neither reading is safe to guess, so refuse.
206
+ if (labelled !== -1 && labelled !== index - 1) {
207
+ throw new InboxRefusal(`"${raw}" is both option ${labelled + 1}'s label and position ${raw}; refusing to guess. Choose by position (1-${choices.length}) or by a label that is not a number:\n${listing}`);
208
+ }
209
+ if (index < 1 || index > choices.length) {
210
+ throw new InboxRefusal(`option ${raw} is out of range; choose 1-${choices.length}:\n${listing}`);
211
+ }
212
+ return choices[index - 1];
213
+ }
214
+ const match = choices.find((label) => label.toLocaleLowerCase() === raw.toLocaleLowerCase());
215
+ if (!match) throw new InboxRefusal(`"${raw}" is not an option; the options are:\n${listing}`);
216
+ return match;
217
+ };
218
+
191
219
  export const runInboxChoose = async (decisionId, value, opts = {}, deps = {}) => {
192
220
  const {
193
221
  env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
194
222
  } = deps;
195
223
  try {
196
- if (!decisionId) throw new InboxRefusal('a decision id is required: commonly inbox choose <decisionId> <value>');
197
- if (value === undefined) throw new InboxRefusal('a value is required: commonly inbox choose <decisionId> <value>');
224
+ if (!decisionId) throw new InboxRefusal('a decision id is required: commonly inbox choose <decisionId> <n|label>');
225
+ if (value === undefined) throw new InboxRefusal('a value is required: commonly inbox choose <decisionId> <n|label>');
198
226
  const {
199
227
  baseUrl, request, account,
200
228
  } = await connect(opts, { env, fetchImpl, sleep });
201
229
  log(formatAccountLine({ account, baseUrl }));
230
+ // The options live on the open queue, not on the decision route, so the
231
+ // choice is resolved against the same queue this account would see in `list`.
232
+ const { items } = await fetchQueue(request);
233
+ const open = items.find((item) => String(item?.id) === String(decisionId) && item?.kind === 'decision');
234
+ if (!open) {
235
+ throw new InboxRefusal(`decision ${decisionId} is not open on this account's queue; nothing to choose`);
236
+ }
237
+ const label = resolveDecisionChoice(open.options, value);
202
238
  const result = await request(`/api/activity/decisions/${encodeURIComponent(decisionId)}/choose`, {
203
239
  method: 'POST',
204
- body: { value },
240
+ body: { value: label },
205
241
  });
206
- log(`chose ${decisionId} = ${value}`);
242
+ log(`chose ${decisionId} = ${label}`);
207
243
  log(`-> ${JSON.stringify(result)}`);
208
244
  return 0;
209
245
  } catch (err) {
@@ -237,7 +273,8 @@ Examples:
237
273
  $ commonly inbox list --cursor-file ~/.commonly/inbox/connector-ops.cursor # only what is new
238
274
  $ commonly inbox list --cursor-file ~/.commonly/inbox/c.cursor --window 300 # widen the re-read to 5m
239
275
  $ commonly inbox ack <attentionItemId>
240
- $ commonly inbox choose <decisionId> 2
276
+ $ commonly inbox choose <decisionId> 2 # second option of the card, by position
277
+ $ commonly inbox choose <decisionId> "Ship it" # or by its label
241
278
 
242
279
  Cost: with no cursor, \`list\` reads the WHOLE queue, one request per 50 items —
243
280
  550 open items is 11 requests. /api/activity is limited to 60 a minute **keyed on
@@ -285,7 +322,7 @@ their attention as runtime events, not from this queue.
285
322
 
286
323
  inbox
287
324
  .command('choose <decisionId> <value>')
288
- .description('Rule a decision card (the source= id from `list`)')
325
+ .description('Rule a decision card (the source= id from `list`). <value> is an option number (1-based) or its label')
289
326
  .option('--token-file <path>', INBOX_TOKEN_HELP)
290
327
  .option('--instance <url>', 'Target Commonly instance')
291
328
  .action(async (decisionId, value, opts) => { process.exit(await runInboxChoose(decisionId, value, opts)); });
@@ -97,6 +97,8 @@ import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '.
97
97
  import { skillsAsText } from '../skill-prompt.js';
98
98
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
99
99
  import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
100
+ import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
101
+ import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
100
102
 
101
103
  // See codex.js for the rationale on bumping the default + env override.
102
104
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -533,6 +535,87 @@ export const claudeResultText = (stdout, { credentials = [] } = {}) => {
533
535
  .trim();
534
536
  };
535
537
 
538
+ // TASK-256: what a public seat's own stream says about its Commonly MCP server.
539
+ // Claude Code reports each server's state in the init event (`connected` or
540
+ // `failed`), and a failed tool call as a `tool_result` with `is_error: true`
541
+ // whose content is the server's text, the shipped MCP's `{status, body,
542
+ // message}` JSON (both measured on Claude Code 2.1.314; `-p` waits for a slow
543
+ // server, so init reports it connected rather than pending). Ready is a positive
544
+ // witness: `connected` in init, or `pending` followed by a Commonly call that
545
+ // returned, since a server may finish starting during the turn. A stream with no
546
+ // init event, or with a Commonly server in any other state, never showed the
547
+ // server working, so the turn is withheld. A failed
548
+ // Commonly call withholds it unless `isAnswerableToolError` names an answer the
549
+ // person can be given (the predicate the OpenCode adapter uses). Only servers
550
+ // that run the shipped Commonly MCP count. Returns the failure, or null.
551
+ const toolResultText = (content) => {
552
+ if (typeof content === 'string') return content;
553
+ if (!Array.isArray(content)) return '';
554
+ return content.filter((part) => part?.type === 'text').map((part) => String(part.text ?? '')).join('');
555
+ };
556
+
557
+ export const claudePublicMcpWitness = (stdout, mcpServers) => {
558
+ const names = (Array.isArray(mcpServers) ? mcpServers : [])
559
+ .filter((server) => server?.name && isShippedCommonlyMcpCommand(server.command))
560
+ .map((server) => String(server.name));
561
+ if (!names.length) return null;
562
+ const events = String(stdout || '').split('\n').map(parseEvent).filter(Boolean);
563
+ const init = events.find((event) => event.type === 'system' && event.subtype === 'init');
564
+ const servers = Array.isArray(init?.mcp_servers) ? init.mcp_servers : [];
565
+ const toolNames = new Map();
566
+ for (const event of events) {
567
+ if (event.type !== 'assistant') continue;
568
+ for (const block of event.message?.content || []) {
569
+ if (block?.type === 'tool_use' && block.id) toolNames.set(block.id, String(block.name || ''));
570
+ }
571
+ }
572
+ const results = events
573
+ .filter((event) => event.type === 'user' && Array.isArray(event.message?.content))
574
+ .flatMap((event) => event.message.content)
575
+ .filter((block) => block?.type === 'tool_result')
576
+ .map((block) => ({ block, tool: toolNames.get(block.tool_use_id) || '' }));
577
+ // Init names a server as declared; tool names carry it with every character
578
+ // outside [A-Za-z0-9_-] turned to `_` (`commonly.me` → `mcp__commonly_me__`,
579
+ // measured on 2.1.314).
580
+ const prefixOf = (name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`;
581
+ for (const name of names) {
582
+ const status = servers.find((server) => server?.name === name)?.status;
583
+ const returned = status === 'pending'
584
+ && results.some(({ block, tool }) => block.is_error !== true && tool.startsWith(prefixOf(name)));
585
+ if (status !== 'connected' && !returned) {
586
+ return { code: PUBLIC_MCP_NOT_READY_CODE, tool: name, error: init ? `status ${status || 'absent'}` : 'no init event' };
587
+ }
588
+ }
589
+ const prefixes = names.map(prefixOf);
590
+ for (const { block, tool } of results) {
591
+ if (block.is_error !== true || !prefixes.some((prefix) => tool.startsWith(prefix))) continue;
592
+ const error = toolResultText(block.content);
593
+ if (!isAnswerableToolError(error)) return { code: PUBLIC_TOOL_FAILED_CODE, tool, error };
594
+ }
595
+ return null;
596
+ };
597
+
598
+ const publicWitnessError = ({ code, tool, error }) => {
599
+ const reason = String(error).replace(/\s+/g, ' ').slice(0, 160) || '(empty error)';
600
+ const what = code === PUBLIC_MCP_NOT_READY_CODE
601
+ ? `Commonly MCP server ${tool} was not shown connected`
602
+ : `Commonly tool ${tool} failed in a way the model must not narrate`;
603
+ const failure = new Error(
604
+ `public seat withheld this turn's reply: ${what} (${reason}), so nothing was posted and the event stays unacked for retry`,
605
+ );
606
+ failure.code = code;
607
+ return failure;
608
+ };
609
+
610
+ // The reply a turn posts. A public seat's turn is witnessed first (TASK-256).
611
+ const turnReply = (stdout, { credentials, publicMcp }) => {
612
+ if (publicMcp) {
613
+ const failure = claudePublicMcpWitness(stdout, publicMcp);
614
+ if (failure) throw publicWitnessError(failure);
615
+ }
616
+ return claudeResultText(stdout, { credentials });
617
+ };
618
+
536
619
  // The failure tail for a non-zero exit. Stream events are mostly system init
537
620
  // and status lines, which would push the terminal error past the 2000-char cut,
538
621
  // so keep the result text, or the lines that are not system or stream events.
@@ -1191,6 +1274,10 @@ export default {
1191
1274
  // undefined and '' as absence itself; it does not need a guard, it needs
1192
1275
  // the value.
1193
1276
  const publicTrust = isConfinedTrust(ctx.environment?.sandbox?.trust);
1277
+ // Only a stranger-facing seat is witnessed (TASK-256). A workspace seat is
1278
+ // confined too, but it answers its own team, and an outage it narrates
1279
+ // there is a signal, not a leak.
1280
+ const witnessedMcp = ctx.environment?.sandbox?.trust === 'public' ? ctx.environment?.mcp : null;
1194
1281
  const workCues = publicTrust ? [] : WORK_CUES;
1195
1282
  const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
1196
1283
  freshSession: !isResume,
@@ -1330,12 +1417,16 @@ export default {
1330
1417
  const contextTokens = claudeContextTokens({
1331
1418
  cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1332
1419
  });
1333
- return { text: claudeResultText(stdout, { credentials: spawnCredentials(spawnCtx) }), newSessionId: sessionId, contextTokens };
1420
+ return {
1421
+ text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1422
+ newSessionId: sessionId,
1423
+ contextTokens,
1424
+ };
1334
1425
  } catch (err) {
1335
1426
  // A public seat's refusal is not a session-store failure, whatever its
1336
1427
  // message happens to contain: it must reach the run loop, never the
1337
1428
  // fresh-session retry below (#2247).
1338
- if (err?.code === PUBLIC_MCP_UNAVAILABLE_CODE) throw err;
1429
+ if ([PUBLIC_MCP_UNAVAILABLE_CODE, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE].includes(err?.code)) throw err;
1339
1430
  // Self-heal against a corrupted session store. If the persisted id is
1340
1431
  // already in use (or claude has forgotten about it), discard it and
1341
1432
  // restart the turn with a fresh UUID. Without this, a single bad
@@ -1393,7 +1484,11 @@ export default {
1393
1484
  cwd: physicalPath(ctx.cwd), sessionId: freshId,
1394
1485
  configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
1395
1486
  });
1396
- return { text: claudeResultText(stdout, { credentials: spawnCredentials(retryCtx) }), newSessionId: freshId, contextTokens };
1487
+ return {
1488
+ text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }),
1489
+ newSessionId: freshId,
1490
+ contextTokens,
1491
+ };
1397
1492
  }
1398
1493
  throw err;
1399
1494
  } finally {
@@ -56,7 +56,7 @@ import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
56
56
  import { resolvePublicSandboxMode } from '../sandbox/mode.js';
57
57
  import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js';
58
58
  import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
59
- import { isAnswerableToolError } from '../public-tool-error.js';
59
+ import { isAnswerableToolError, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
60
60
  import { startInstanceProxy } from '../instance-proxy.js';
61
61
  import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
62
62
 
@@ -390,7 +390,7 @@ const makeEventParser = () => {
390
390
  // stays beside the adapter-agnostic readiness witness (TASK-256): it sees the
391
391
  // runtime's own view of each call, and the witness sees a server that never
392
392
  // started, so each catches what the other cannot.
393
- export const PUBLIC_TOOL_FAILED_CODE = 'public_tool_failed';
393
+ export { PUBLIC_TOOL_FAILED_CODE };
394
394
 
395
395
  export const publicToolFailure = (toolErrors, mcpServers) => {
396
396
  const prefixes = (Array.isArray(mcpServers) ? mcpServers : [])
@@ -33,6 +33,12 @@
33
33
 
34
34
  export const ANSWERABLE_API_STATUSES = new Set([400, 403, 404, 409, 410, 413, 422]);
35
35
 
36
+ // The two ways a public turn is withheld over its Commonly MCP server: a tool
37
+ // call failed in a way the model must not narrate, or the server was never shown
38
+ // working. Both leave the event unacked for retry.
39
+ export const PUBLIC_TOOL_FAILED_CODE = 'public_tool_failed';
40
+ export const PUBLIC_MCP_NOT_READY_CODE = 'public_mcp_not_ready';
41
+
36
42
  const INVALID_ARGUMENTS_RE = /^The \S+ tool was called with invalid arguments: /;
37
43
 
38
44
  const parsePayload = (text) => {
@@ -7,6 +7,8 @@
7
7
  * retry policy here so every adapter gets the same bounded behavior.
8
8
  */
9
9
 
10
+ import { PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from './public-tool-error.js';
11
+
10
12
  export const SPAWN_FAILURE_CLASS = Object.freeze({
11
13
  // The provider refused the request's CONTENT. Re-sending the same content
12
14
  // can only be refused again, so this class is never retried: the run loop
@@ -121,6 +123,15 @@ const errorText = (error) => [
121
123
 
122
124
  export const classifySpawnFailure = (error) => {
123
125
  const text = errorText(error);
126
+ // A public seat's withheld turn (TASK-256) quotes the tool's own error, which
127
+ // is vendor text: the code decides, so the event stays unacked for retry and
128
+ // is never acked as refused or parked behind a quota or configuration
129
+ // cooldown by that text. A throttle keeps its backoff, which also retries.
130
+ if (error?.code === PUBLIC_TOOL_FAILED_CODE || error?.code === PUBLIC_MCP_NOT_READY_CODE) {
131
+ return error?.status === 429 || RATE_LIMIT_RE.test(text)
132
+ ? SPAWN_FAILURE_CLASS.RATE_LIMIT
133
+ : SPAWN_FAILURE_CLASS.RUNTIME;
134
+ }
124
135
  // First: the refusal text carries none of the words below today, but a
125
136
  // refusal must never be read as a transient condition and retried.
126
137
  if (CONTENT_REFUSED_RE.test(text)) return SPAWN_FAILURE_CLASS.CONTENT_REFUSED;