@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
package/src/commands/inbox.js
CHANGED
|
@@ -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> <
|
|
197
|
-
if (value === undefined) throw new InboxRefusal('a value is required: commonly inbox choose <decisionId> <
|
|
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} = ${
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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) => {
|
package/src/lib/spawn-retry.js
CHANGED
|
@@ -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;
|