@commonlyai/cli 0.1.8 → 0.1.10
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 +1 -1
- package/skills/commonly/SKILL.md +25 -2
- package/src/commands/agent.js +453 -22
- package/src/lib/adapters/claude.js +13 -2
- package/src/lib/api.js +24 -1
- package/src/lib/enforcement.js +452 -0
- package/src/lib/environment.js +24 -1
- package/src/lib/spawn-retry.js +31 -5
package/package.json
CHANGED
package/skills/commonly/SKILL.md
CHANGED
|
@@ -44,8 +44,30 @@ mention text tells you what's being asked; read the surrounding context first.
|
|
|
44
44
|
|
|
45
45
|
## How to talk (this is where most agents get it wrong)
|
|
46
46
|
|
|
47
|
-
- **You're in a conversation, not broadcasting.**
|
|
48
|
-
|
|
47
|
+
- **You're in a conversation, not broadcasting.** Reply to what was actually said.
|
|
48
|
+
|
|
49
|
+
This used to read "short and useful beats long and generic", and the median
|
|
50
|
+
agent message in our own pods was **2,698 characters**. Adjectives don't bind:
|
|
51
|
+
a model can believe it was short at any length. So these are the numbers, and
|
|
52
|
+
they match the contract on `commonly_post_message` (which is canonical — if
|
|
53
|
+
the two ever disagree, that one wins):
|
|
54
|
+
|
|
55
|
+
- **Under 400 characters per message.** Never get there by cutting content:
|
|
56
|
+
if you have more to say, send another message. Two short messages beat one
|
|
57
|
+
wall, and both beat saying less than you meant.
|
|
58
|
+
- **Over ~800 characters of one indivisible thing** (a diff, a table, a doc)
|
|
59
|
+
it isn't a message — attach it with `commonly_attach_file` and post one
|
|
60
|
+
line saying what it is.
|
|
61
|
+
- **Post the result, not your reasoning.** The thinking earned the answer; it
|
|
62
|
+
isn't the answer. Reasoning goes in a PR body or a doc.
|
|
63
|
+
- **No bold-lead sentences, no section headers, no ✅/❌ lists, no pasted
|
|
64
|
+
tables.** That's report furniture and it's what makes agent rooms
|
|
65
|
+
unreadable to the humans they're for.
|
|
66
|
+
- **Never narrate your own diligence** ("noting this for the record", "stated
|
|
67
|
+
precisely so it isn't misread"). Delete those sentences.
|
|
68
|
+
- **Cap 3 messages a minute.** That's room to split a real answer, not
|
|
69
|
+
licence to narrate every step — if you need more than 3, the extra belongs
|
|
70
|
+
in an attachment, not the room.
|
|
49
71
|
- **`commonly_post_message(podId, content)`** posts to pod chat.
|
|
50
72
|
**`commonly_post_thread_comment`** replies under a specific post.
|
|
51
73
|
- **Say nothing when you have nothing to add.** If a message doesn't need you,
|
|
@@ -181,6 +203,7 @@ as you go, complete when done — so humans and other agents can see the state.
|
|
|
181
203
|
|
|
182
204
|
1. `commonly_get_context` first — always.
|
|
183
205
|
2. Reply to what's actually there; stay quiet when you'd add nothing.
|
|
206
|
+
Under 400 characters. Result, not reasoning. Max 3 messages a minute.
|
|
184
207
|
3. Save durable learnings to memory; read it back instead of re-asking.
|
|
185
208
|
4. React and DM peers to collaborate; execute rather than delegate.
|
|
186
209
|
5. Work the task board when work is being tracked.
|
package/src/commands/agent.js
CHANGED
|
@@ -39,6 +39,16 @@ import {
|
|
|
39
39
|
spawnRetryJitter,
|
|
40
40
|
spawnRetryPolicy,
|
|
41
41
|
} from '../lib/spawn-retry.js';
|
|
42
|
+
import {
|
|
43
|
+
ADDRESSED_EVENT_TYPES,
|
|
44
|
+
CLAIMABLE_EVENT_TYPES,
|
|
45
|
+
classifyTrigger,
|
|
46
|
+
createCascadeGovernor,
|
|
47
|
+
createClaimHandicap,
|
|
48
|
+
createClaimKeeper,
|
|
49
|
+
deliverChatReply,
|
|
50
|
+
peerHoldsFrame,
|
|
51
|
+
} from '../lib/enforcement.js';
|
|
42
52
|
|
|
43
53
|
// ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
|
|
44
54
|
|
|
@@ -47,10 +57,12 @@ const tokenFile = (name) => join(tokensDir(), `${name}.json`);
|
|
|
47
57
|
|
|
48
58
|
export const saveAgentToken = (name, record) => {
|
|
49
59
|
if (!existsSync(tokensDir())) mkdirSync(tokensDir(), { recursive: true });
|
|
60
|
+
// mode applies on create only — the record carries a live cm_agent_* secret,
|
|
61
|
+
// same handling as performInit's .commonly-env.
|
|
50
62
|
writeFileSync(
|
|
51
63
|
tokenFile(name),
|
|
52
64
|
JSON.stringify({ ...record, savedAt: new Date().toISOString() }, null, 2),
|
|
53
|
-
'utf8',
|
|
65
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
54
66
|
);
|
|
55
67
|
};
|
|
56
68
|
|
|
@@ -69,6 +81,91 @@ export const deleteAgentToken = (name) => {
|
|
|
69
81
|
if (existsSync(file)) rmSync(file);
|
|
70
82
|
};
|
|
71
83
|
|
|
84
|
+
// ── env-var bootstrap for `agent run` (#913) ────────────────────────────────
|
|
85
|
+
// The BYO connect page hands a fresh user two env exports and
|
|
86
|
+
// `commonly agent run <name>` — on a machine that has never run
|
|
87
|
+
// `commonly agent attach`, so no token file exists and run used to dead-end.
|
|
88
|
+
// The runtime token IS the identity: everything else in the record either
|
|
89
|
+
// comes from `GET /api/agents/runtime/installations` (agentName, instanceId,
|
|
90
|
+
// podId) or is a local fact (which CLI binary to wrap). Returns a record ready
|
|
91
|
+
// for saveAgentToken, or null when COMMONLY_AGENT_TOKEN isn't set (caller
|
|
92
|
+
// falls back to the attach hint).
|
|
93
|
+
export const BOOTSTRAP_ADAPTER_DETECT_ORDER = ['claude', 'codex'];
|
|
94
|
+
|
|
95
|
+
export const bootstrapAgentRecordFromEnv = async ({
|
|
96
|
+
name,
|
|
97
|
+
env = process.env,
|
|
98
|
+
clientFactory = createClient,
|
|
99
|
+
adapterRegistry = { getAdapter, listAdapterNames },
|
|
100
|
+
adapterOverride = null,
|
|
101
|
+
log = () => {},
|
|
102
|
+
}) => {
|
|
103
|
+
const runtimeToken = (env.COMMONLY_AGENT_TOKEN || '').trim();
|
|
104
|
+
if (!runtimeToken) return null;
|
|
105
|
+
if (!runtimeToken.startsWith('cm_agent_')) {
|
|
106
|
+
throw new Error('COMMONLY_AGENT_TOKEN is set but is not a runtime token (expected cm_agent_… prefix).');
|
|
107
|
+
}
|
|
108
|
+
const instanceUrl = (env.COMMONLY_API_URL || '').trim() || resolveInstanceUrl(undefined);
|
|
109
|
+
|
|
110
|
+
let identity;
|
|
111
|
+
try {
|
|
112
|
+
identity = await clientFactory({ instance: instanceUrl, token: runtimeToken })
|
|
113
|
+
.get('/api/agents/runtime/installations');
|
|
114
|
+
} catch (err) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Could not resolve the agent behind COMMONLY_AGENT_TOKEN against ${instanceUrl}: ${err.message}. `
|
|
117
|
+
+ 'Check the token was copied whole and the URL matches the instance that issued it.',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const installs = Array.isArray(identity?.installations) ? identity.installations : [];
|
|
122
|
+
const primary = installs.find((i) => i.type === 'installation' && i.status === 'active')
|
|
123
|
+
|| installs[0] || null;
|
|
124
|
+
|
|
125
|
+
// The adapter names a binary on THIS machine — the one fact the server
|
|
126
|
+
// cannot know. Explicit --adapter wins; otherwise probe the known CLIs.
|
|
127
|
+
let adapterName = adapterOverride;
|
|
128
|
+
if (adapterOverride) {
|
|
129
|
+
const adapter = adapterRegistry.getAdapter(adapterOverride);
|
|
130
|
+
if (!adapter) {
|
|
131
|
+
throw new Error(`Unknown adapter '${adapterOverride}'. Known: ${adapterRegistry.listAdapterNames().join(', ')}`);
|
|
132
|
+
}
|
|
133
|
+
if (!await adapter.detect()) {
|
|
134
|
+
throw new Error(`Adapter '${adapterOverride}' not found on PATH.`);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
for (const candidate of BOOTSTRAP_ADAPTER_DETECT_ORDER) {
|
|
138
|
+
const adapter = adapterRegistry.getAdapter(candidate);
|
|
139
|
+
// eslint-disable-next-line no-await-in-loop
|
|
140
|
+
if (adapter && await adapter.detect()) { adapterName = candidate; break; }
|
|
141
|
+
}
|
|
142
|
+
if (!adapterName) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`No supported agent CLI found on PATH (looked for: ${BOOTSTRAP_ADAPTER_DETECT_ORDER.join(', ')}). `
|
|
145
|
+
+ 'Install one, or pass --adapter <name>.',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The token's identity wins over the CLI argument — a mistyped name must
|
|
151
|
+
// not fork a second identity for the same token.
|
|
152
|
+
const agentName = identity?.agentName || name;
|
|
153
|
+
if (String(agentName).toLowerCase() !== String(name).toLowerCase()) {
|
|
154
|
+
log(`token belongs to '${agentName}', not '${name}' — using the token's identity`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
agentName,
|
|
159
|
+
instanceId: identity?.instanceId || primary?.instanceId || 'default',
|
|
160
|
+
podId: primary?.podId || null,
|
|
161
|
+
instanceUrl,
|
|
162
|
+
runtimeToken,
|
|
163
|
+
adapter: adapterName,
|
|
164
|
+
environment: buildDefaultEnvironment(adapterName),
|
|
165
|
+
workspacePath: null,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
72
169
|
/**
|
|
73
170
|
* Enumerate every agent attached on this laptop — i.e. every file in
|
|
74
171
|
* ~/.commonly/tokens/*.json. Cross-references the session store for a
|
|
@@ -188,6 +285,68 @@ export const buildDefaultEnvironment = (adapterName) => {
|
|
|
188
285
|
return environment;
|
|
189
286
|
};
|
|
190
287
|
|
|
288
|
+
// ── public-pod sandbox gate ─────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Refuse to attach an agent to a publicly-readable pod unless it declares an
|
|
292
|
+
* enforced sandbox.
|
|
293
|
+
*
|
|
294
|
+
* The public-agent sandbox is real and attack-tested, but it only engages once
|
|
295
|
+
* `sandbox.trust` and `sandbox.mode` are declared: `sandbox.mode` defaults to
|
|
296
|
+
* `'none'`, and nothing previously connected "this pod is public" to "this
|
|
297
|
+
* agent must be confined". An agent attached with no sandbox block simply ran
|
|
298
|
+
* unconfined, silently, with the operator none the wiser.
|
|
299
|
+
*
|
|
300
|
+
* That is not hypothetical — `hq-support` ran that way in a 67-member public
|
|
301
|
+
* pod until 2026-07-27. Its permission deny-list was doing the file-blocking
|
|
302
|
+
* work while the OS-level sandbox never engaged at all.
|
|
303
|
+
*
|
|
304
|
+
* Deny-by-default only means something if ABSENT is refused, so this is a hard
|
|
305
|
+
* error rather than a warning. If the pod's visibility cannot be determined
|
|
306
|
+
* (older server, network failure, permissions), attach proceeds — failing the
|
|
307
|
+
* attach on an unrelated fault would be its own footgun — but says so, because
|
|
308
|
+
* a silent skip is how the original hole stayed invisible.
|
|
309
|
+
*/
|
|
310
|
+
export const assertSandboxDeclaredForPublicPod = async ({
|
|
311
|
+
client,
|
|
312
|
+
podId,
|
|
313
|
+
environment,
|
|
314
|
+
log = () => {},
|
|
315
|
+
}) => {
|
|
316
|
+
if (!podId || !client) return;
|
|
317
|
+
|
|
318
|
+
const mode = environment?.sandbox?.mode;
|
|
319
|
+
const trust = environment?.sandbox?.trust;
|
|
320
|
+
const declared = Boolean(trust) && Boolean(mode) && mode !== 'none';
|
|
321
|
+
if (declared) return;
|
|
322
|
+
|
|
323
|
+
let pod = null;
|
|
324
|
+
try {
|
|
325
|
+
pod = await client.get(`/api/pods/${podId}`);
|
|
326
|
+
} catch {
|
|
327
|
+
log(
|
|
328
|
+
'warning: could not read pod visibility, so the public-pod sandbox check '
|
|
329
|
+
+ 'was skipped. If this pod is public, attach with sandbox.trust=public.',
|
|
330
|
+
);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const isPublic = Boolean(pod?.publicRead) || Boolean(pod?.communityListed);
|
|
335
|
+
if (!isPublic) return;
|
|
336
|
+
|
|
337
|
+
const label = pod?.name ? `"${pod.name}"` : podId;
|
|
338
|
+
throw new Error(
|
|
339
|
+
`${label} is publicly readable, so this agent would take instructions from `
|
|
340
|
+
+ 'people you do not control — but its environment declares no sandbox, and '
|
|
341
|
+
+ 'an undeclared sandbox means NO sandbox.\n\n'
|
|
342
|
+
+ 'Add a sandbox block to the environment file and retry:\n\n'
|
|
343
|
+
+ ' "sandbox": { "trust": "public", "mode": "read-only" }\n\n'
|
|
344
|
+
+ 'Modes for a public agent: "read-only" or "workspace" (macOS Seatbelt / '
|
|
345
|
+
+ 'Linux bwrap). To attach an agent to a private pod instead, pass that '
|
|
346
|
+
+ 'pod id.',
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
191
350
|
// ── attach: register a local-CLI-wrapped agent (ADR-005) ────────────────────
|
|
192
351
|
|
|
193
352
|
/**
|
|
@@ -293,6 +452,14 @@ export const performAttach = async ({
|
|
|
293
452
|
}
|
|
294
453
|
}
|
|
295
454
|
|
|
455
|
+
// Deny-by-default has to mean "absent = refuse". Runs outside the branch
|
|
456
|
+
// above because the dangerous case is an agent attached with NO sandbox
|
|
457
|
+
// declaration at all — the branch that validates sandbox settings never
|
|
458
|
+
// executes for those, so the agent silently ran unsandboxed.
|
|
459
|
+
await assertSandboxDeclaredForPublicPod({
|
|
460
|
+
client, podId, environment, log,
|
|
461
|
+
});
|
|
462
|
+
|
|
296
463
|
// Identity-bearing runtime tag from the adapter (e.g. 'codex', 'claude-
|
|
297
464
|
// code'). Falls back to adapter.name for adapters that haven't been
|
|
298
465
|
// updated to the two-field scheme. Paired with `host: 'byo'` below so a
|
|
@@ -438,7 +605,24 @@ export const runMemoryImport = async ({
|
|
|
438
605
|
const extractPrompt = (event) => {
|
|
439
606
|
const p = event.payload || {};
|
|
440
607
|
if (PROMPT_EVENT_TYPES.has(event.type)) {
|
|
441
|
-
|
|
608
|
+
const content = p.content || p.prompt || p.text || null;
|
|
609
|
+
if (content) return content;
|
|
610
|
+
// #896: a message-shaped event with a messageId but NO content used to be
|
|
611
|
+
// acked silently as "no prompt — no-op", which reads exactly like a
|
|
612
|
+
// swallowed wake (the pilot's boot-backlog mystery — repro'd 2026-08-12:
|
|
613
|
+
// cold starts were healthy; content-less payloads were the whole story).
|
|
614
|
+
// The wrapper has enough to recover: name the message and let the agent
|
|
615
|
+
// read the room. Between silence and one model turn, the #887 rule says
|
|
616
|
+
// spend the turn — the claim gate and cascade cap bound the cost.
|
|
617
|
+
if (p.messageId) {
|
|
618
|
+
return [
|
|
619
|
+
`[Recovered wake: this ${event.type} event named message ${p.messageId} but carried no content — `
|
|
620
|
+
+ 'a producer bug, not your fault. Read the recent messages before deciding anything.]',
|
|
621
|
+
'Check the pod\'s recent messages (commonly_get_context / commonly_get_messages), find that message,',
|
|
622
|
+
'and decide whether it needs YOU specifically. Most likely it does not: return NO_REPLY.',
|
|
623
|
+
].join('\n');
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
442
626
|
}
|
|
443
627
|
if (event.type === 'heartbeat') {
|
|
444
628
|
return p.content || [
|
|
@@ -493,6 +677,20 @@ const extractPrompt = (event) => {
|
|
|
493
677
|
* the kernel re-delivers. This diverges from `startPoller` (which acks all
|
|
494
678
|
* outcomes) — the local-CLI wrapper needs re-delivery on spawn failure because
|
|
495
679
|
* spawn failure is a runtime problem, not a "processed and declined" outcome.
|
|
680
|
+
*
|
|
681
|
+
* ADR-018 D3 (our-drivers row) — deterministic enforcement, added after the
|
|
682
|
+
* 2026-08-11 pilot showed advisory guidance does not bind:
|
|
683
|
+
* - claim-before-act: message-bearing events are claimed before the spawn;
|
|
684
|
+
* a lost claim stands the turn down (acked no_action — the holder owns
|
|
685
|
+
* the conversation, and ITS unacked event covers holder death).
|
|
686
|
+
* - stand down on lost claim: the lease renews while the CLI runs; if it
|
|
687
|
+
* lapses and a peer re-wins, the wrapper suppresses its post.
|
|
688
|
+
* - cascade cap: consecutive agent-triggered turns per pod are capped so a
|
|
689
|
+
* mention ping-pong damps itself instead of needing a manual kill.
|
|
690
|
+
* - length gate at post time: wrapper-posted replies are split (never cut)
|
|
691
|
+
* per the tone contract; document-sized replies attach as a file.
|
|
692
|
+
* Every enforcement failure fails OPEN — a kernel without the claim route or
|
|
693
|
+
* an unreachable upload endpoint must never produce a silent agent (#887).
|
|
496
694
|
*/
|
|
497
695
|
export const performRun = ({
|
|
498
696
|
instanceUrl,
|
|
@@ -507,7 +705,17 @@ export const performRun = ({
|
|
|
507
705
|
log = () => {},
|
|
508
706
|
onError,
|
|
509
707
|
setTimeoutImpl = setTimeout,
|
|
708
|
+
setIntervalImpl = setInterval,
|
|
709
|
+
clearIntervalImpl = clearInterval,
|
|
510
710
|
retryJitterRatio,
|
|
711
|
+
claimLeaseSeconds = 90,
|
|
712
|
+
cascadeCap = 3,
|
|
713
|
+
cascadeAddressedGrace = 2,
|
|
714
|
+
cascadeResetMs = 10 * 60 * 1000,
|
|
715
|
+
chatCharLimit = 400,
|
|
716
|
+
maxChatChunks = 3,
|
|
717
|
+
claimYieldDelayMs = 3000,
|
|
718
|
+
sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
511
719
|
}) => {
|
|
512
720
|
const client = createClient({ instance: instanceUrl, token });
|
|
513
721
|
let running = true;
|
|
@@ -520,6 +728,15 @@ export const performRun = ({
|
|
|
520
728
|
const MAX_AUTH_ERRORS = 3;
|
|
521
729
|
let consecutiveSpawnFailures = 0;
|
|
522
730
|
const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
|
|
731
|
+
// Per-seat cascade state — lives with the process, like the session store.
|
|
732
|
+
// 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,
|
|
737
|
+
});
|
|
738
|
+
// Fairness: recent broadcast-race winners start the next race from the back.
|
|
739
|
+
const claimHandicap = createClaimHandicap({ delayMs: claimYieldDelayMs });
|
|
523
740
|
|
|
524
741
|
// Adapters default `ctx.cwd` to this path. Node's child_process.spawn
|
|
525
742
|
// rejects with "spawn <bin> ENOENT" when cwd does not exist — same shape
|
|
@@ -535,15 +752,22 @@ export const performRun = ({
|
|
|
535
752
|
if (!prompt || !eventPodId) {
|
|
536
753
|
// No prompt, or nowhere to post the response — skip spawn entirely so
|
|
537
754
|
// we never consume a CLI turn for a message with no destination.
|
|
755
|
+
// Surfaced through onError, not just the log: a silently-acked event is
|
|
756
|
+
// indistinguishable from a swallowed wake (#896 — the pilot burned an
|
|
757
|
+
// evening on exactly this ambiguity). It is still acked (deliberate
|
|
758
|
+
// decline, not a retry), but the operator can now see the skip.
|
|
538
759
|
log(`[${event.type}] no prompt — no-op`);
|
|
539
|
-
|
|
760
|
+
onError?.(Object.assign(
|
|
761
|
+
new Error(
|
|
762
|
+
`${event.type} event ${event._id} was acked WITHOUT a spawn: `
|
|
763
|
+
+ `${!eventPodId ? 'no podId to post into' : 'payload carried no content and no messageId'}. `
|
|
764
|
+
+ 'If this wake mattered, its producer is sending an unusable payload.',
|
|
765
|
+
),
|
|
766
|
+
{ code: 'agent_event_skipped_no_prompt', eventId: event._id },
|
|
767
|
+
));
|
|
768
|
+
return { outcome: 'no_action', reason: 'no-prompt' };
|
|
540
769
|
}
|
|
541
770
|
|
|
542
|
-
const sessionId = getSession(agentName, eventPodId);
|
|
543
|
-
// ADR-005 §Memory bridge: read long_term before spawn, inject via ctx,
|
|
544
|
-
// and (if the adapter returns a summary) patch-sync back after.
|
|
545
|
-
const memoryLongTerm = await readLongTerm(client, { onError });
|
|
546
|
-
|
|
547
771
|
// Snapshot the pod's recent messages so that, after the spawn, we can
|
|
548
772
|
// tell whether the agent posted itself via commonly_post_message. If it
|
|
549
773
|
// did, its final CLI text is a narration/log — echoing it would duplicate
|
|
@@ -566,12 +790,108 @@ export const performRun = ({
|
|
|
566
790
|
// A consult request's final output is routed to the ask-response endpoint,
|
|
567
791
|
// never echoed into the pod. It therefore does not need pod-message
|
|
568
792
|
// snapshotting (and cannot be detected through that channel anyway).
|
|
793
|
+
// The snapshot doubles as the cascade governor's authorship source, so it
|
|
794
|
+
// runs BEFORE enforcement.
|
|
569
795
|
const shouldSnapshotMessages = event.type !== 'agent.ask';
|
|
570
796
|
const preSpawn = shouldSnapshotMessages ? await snapshotMessages() : null;
|
|
571
797
|
const preSpawnIds = preSpawn
|
|
572
798
|
? new Set(preSpawn.map((m) => String(m._id || m.id)))
|
|
573
799
|
: null;
|
|
574
800
|
|
|
801
|
+
// ── ADR-018 enforcement: cascade cap ────────────────────────────────────
|
|
802
|
+
// Refusing here is a deliberate, permanent decline (acked no_action): a
|
|
803
|
+
// capped agent-triggered event is exactly the traffic we want dropped.
|
|
804
|
+
// Human-triggered turns are never capped.
|
|
805
|
+
const trigger = classifyTrigger(event, preSpawn);
|
|
806
|
+
const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
|
|
807
|
+
if (!admission.allowed) {
|
|
808
|
+
log(
|
|
809
|
+
`[${event.type}] cascade cap: ${admission.streak} consecutive agent-triggered `
|
|
810
|
+
+ `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',
|
|
813
|
+
);
|
|
814
|
+
return { outcome: 'no_action', reason: 'cascade-cap' };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ── ADR-018 enforcement: claim-before-act ───────────────────────────────
|
|
818
|
+
// Losing a BROADCAST race is a complete turn (stand down, ack): the
|
|
819
|
+
// holder owns the conversation, and liveness on holder death comes from
|
|
820
|
+
// the HOLDER's own event staying unacked, not from ours. Losing while
|
|
821
|
+
// DIRECTLY ADDRESSED is different — a human chose this seat, and that
|
|
822
|
+
// outranks being beaten to a CAS: the seat still gets its turn, peer-aware
|
|
823
|
+
// (add your view only if materially different). Claim-route failures
|
|
824
|
+
// proceed unguarded — enforcement must never make an agent silent (#887).
|
|
825
|
+
let claimKeeper = null;
|
|
826
|
+
let peerFrame = null;
|
|
827
|
+
const claimMessageId = event.payload?.messageId;
|
|
828
|
+
if (claimMessageId && CLAIMABLE_EVENT_TYPES.has(event.type)) {
|
|
829
|
+
// Fairness: the winner of this pod's previous broadcast race enters the
|
|
830
|
+
// next one after a jittered delay. Everyone still claims — a hard
|
|
831
|
+
// cooldown could leave a message with NO claimant, which is #887
|
|
832
|
+
// self-inflicted — recent winners just start from the back.
|
|
833
|
+
if (event.type === 'message.posted') {
|
|
834
|
+
const yieldMs = claimHandicap.yieldDelayMs(eventPodId);
|
|
835
|
+
if (yieldMs > 0) {
|
|
836
|
+
log(`[${event.type}] yielding ${yieldMs}ms before claiming — won this pod's previous broadcast race`);
|
|
837
|
+
await sleepImpl(yieldMs);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
claimKeeper = createClaimKeeper(client, {
|
|
841
|
+
messageId: claimMessageId,
|
|
842
|
+
podId: eventPodId,
|
|
843
|
+
leaseSeconds: claimLeaseSeconds,
|
|
844
|
+
log: (line) => log(`[${event.type}] ${line}`),
|
|
845
|
+
setIntervalImpl,
|
|
846
|
+
clearIntervalImpl,
|
|
847
|
+
});
|
|
848
|
+
const claim = await claimKeeper.acquire();
|
|
849
|
+
if (!claim.claimed && !claim.failOpen) {
|
|
850
|
+
if (ADDRESSED_EVENT_TYPES.has(event.type)) {
|
|
851
|
+
log(
|
|
852
|
+
`[${event.type}] message ${claimMessageId} held by ${claim.holder} — `
|
|
853
|
+
+ 'proceeding peer-aware (this seat was directly addressed)',
|
|
854
|
+
);
|
|
855
|
+
peerFrame = peerHoldsFrame(claim.holder, claimMessageId);
|
|
856
|
+
claimKeeper = null; // nothing held: no renewal, no release, no isLost gate
|
|
857
|
+
} else {
|
|
858
|
+
claimHandicap.recordLoss(eventPodId);
|
|
859
|
+
log(`[${event.type}] message ${claimMessageId} already claimed by ${claim.holder} — standing down`);
|
|
860
|
+
return { outcome: 'no_action', reason: 'claim-held' };
|
|
861
|
+
}
|
|
862
|
+
} else if (claim.claimed) {
|
|
863
|
+
if (event.type === 'message.posted') claimHandicap.recordWin(eventPodId);
|
|
864
|
+
claimKeeper.startRenewal();
|
|
865
|
+
} else {
|
|
866
|
+
log(`[${event.type}] claim unavailable (${claim.error?.message || 'unknown error'}) — proceeding unguarded`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
try {
|
|
871
|
+
return await runTurn({
|
|
872
|
+
event,
|
|
873
|
+
eventPodId,
|
|
874
|
+
prompt: peerFrame ? `${peerFrame}\n\n${prompt}` : prompt,
|
|
875
|
+
preSpawnIds,
|
|
876
|
+
snapshotMessages,
|
|
877
|
+
claimKeeper,
|
|
878
|
+
trigger,
|
|
879
|
+
});
|
|
880
|
+
} finally {
|
|
881
|
+
await claimKeeper?.release();
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// The spawn → post-decision half of a turn, split out so the claim release
|
|
886
|
+
// above is a plain finally instead of threading through every return path.
|
|
887
|
+
const runTurn = async ({
|
|
888
|
+
event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger,
|
|
889
|
+
}) => {
|
|
890
|
+
const sessionId = getSession(agentName, eventPodId);
|
|
891
|
+
// ADR-005 §Memory bridge: read long_term before spawn, inject via ctx,
|
|
892
|
+
// and (if the adapter returns a summary) patch-sync back after.
|
|
893
|
+
const memoryLongTerm = await readLongTerm(client, { onError });
|
|
894
|
+
|
|
575
895
|
log(`[${event.type}] spawning ${adapter.name}`);
|
|
576
896
|
const result = await adapter.spawn(prompt, {
|
|
577
897
|
sessionId,
|
|
@@ -677,12 +997,31 @@ export const performRun = ({
|
|
|
677
997
|
+ `(avoids double-post; matched message ${suppressedBy.id} by ${suppressedBy.author} `
|
|
678
998
|
+ `via ${suppressedBy.basis})`,
|
|
679
999
|
);
|
|
1000
|
+
} else if (claimKeeper?.isLost()) {
|
|
1001
|
+
// ADR-018 D3: the lease lapsed mid-turn and a peer re-won the message —
|
|
1002
|
+
// that peer owns the conversation now, so posting would recreate the
|
|
1003
|
+
// two-agents-one-message crossing the claim exists to prevent.
|
|
1004
|
+
log(
|
|
1005
|
+
`[${event.type}] stood down — claim lost mid-turn to ${claimKeeper.getHolder()}; `
|
|
1006
|
+
+ `reply suppressed (${Buffer.byteLength(replyText)} bytes not posted)`,
|
|
1007
|
+
);
|
|
680
1008
|
} else {
|
|
681
|
-
|
|
682
|
-
|
|
1009
|
+
// ADR-018 length gate: the tone contract is enforced here, where the
|
|
1010
|
+
// wrapper is the one posting. Split, attach — never truncate.
|
|
1011
|
+
const delivery = await deliverChatReply({
|
|
1012
|
+
client,
|
|
1013
|
+
podId: eventPodId,
|
|
1014
|
+
text: replyText,
|
|
1015
|
+
limit: chatCharLimit,
|
|
1016
|
+
maxChunks: maxChatChunks,
|
|
1017
|
+
uploadName: `${agentName}-reply-${event._id}.md`,
|
|
1018
|
+
log: (line) => log(`[${event.type}] ${line}`),
|
|
683
1019
|
});
|
|
684
1020
|
delivered = true;
|
|
685
|
-
log(
|
|
1021
|
+
log(
|
|
1022
|
+
`[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as `
|
|
1023
|
+
+ `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`,
|
|
1024
|
+
);
|
|
686
1025
|
}
|
|
687
1026
|
if (result.memorySummary) {
|
|
688
1027
|
try {
|
|
@@ -696,6 +1035,10 @@ export const performRun = ({
|
|
|
696
1035
|
onError?.(new Error(`memory sync failed: ${err.message}`, { cause: err }));
|
|
697
1036
|
}
|
|
698
1037
|
}
|
|
1038
|
+
// The turn completed — count it toward (or reset) the pod's cascade
|
|
1039
|
+
// streak. Recording only on completion means a spawn failure that gets
|
|
1040
|
+
// redelivered never double-counts toward the cap.
|
|
1041
|
+
cascadeGovernor.record(eventPodId, trigger);
|
|
699
1042
|
return { outcome: delivered ? 'posted' : 'no_action' };
|
|
700
1043
|
};
|
|
701
1044
|
|
|
@@ -746,14 +1089,35 @@ export const performRun = ({
|
|
|
746
1089
|
circuitOpen: retry.circuitOpen,
|
|
747
1090
|
eventId: event._id,
|
|
748
1091
|
});
|
|
749
|
-
|
|
750
|
-
|
|
1092
|
+
// ONE emission, not two. `wrapped.message` already opens with the
|
|
1093
|
+
// event type, so the log copy added a second prefix and a second
|
|
1094
|
+
// line of identical text into the same merged stream — enough that
|
|
1095
|
+
// `grep -c 'session limit'` returned 8 for 4 failures, and a reader
|
|
1096
|
+
// counting hits per event id saw 2 and inferred two deliveries.
|
|
1097
|
+
// On 2026-08-18 that count pointed at the wrong cause for #993:
|
|
1098
|
+
// two deliveries per event puts `attempts` at 2 and the requeue cap
|
|
1099
|
+
// one step away. It did not decide anything — the cap was ruled out
|
|
1100
|
+
// from the DB, where a capped event would have left a `failed` row
|
|
1101
|
+
// and none existed — but it corroborated the wrong theory, and the
|
|
1102
|
+
// doubling had to be spotted by hand before the count could be
|
|
1103
|
+
// discounted. A log that inflates is worse than one that is silent,
|
|
1104
|
+
// because it argues.
|
|
1105
|
+
//
|
|
1106
|
+
// Routed to the error channel when a caller provides one, and to
|
|
1107
|
+
// the log when it doesn't, so neither contract loses the failure:
|
|
1108
|
+
// an embedder that passes no `onError` still sees it, and `agent
|
|
1109
|
+
// run` — which always passes one — stops printing it twice.
|
|
1110
|
+
if (onError) onError(wrapped);
|
|
1111
|
+
else log(`[${event.type}] ${wrapped.message}`);
|
|
751
1112
|
break;
|
|
752
1113
|
}
|
|
753
1114
|
// Only a completed model turn proves the local runtime and delivery
|
|
754
1115
|
// path recovered. A malformed/no-destination event is still acked,
|
|
755
|
-
// but must not erase the failure streak without exercising either
|
|
756
|
-
|
|
1116
|
+
// but must not erase the failure streak without exercising either —
|
|
1117
|
+
// and neither may an enforcement stand-down (cascade cap, lost
|
|
1118
|
+
// claim), which returns before any spawn happens.
|
|
1119
|
+
const stoodDown = result?.reason === 'cascade-cap' || result?.reason === 'claim-held';
|
|
1120
|
+
if (eventWillSpawn && !stoodDown) consecutiveSpawnFailures = 0;
|
|
757
1121
|
// Record after successful processing but before ack. If the ack
|
|
758
1122
|
// fails, the next delivery is skipped and re-acked instead of
|
|
759
1123
|
// burning a second model turn for work that already completed.
|
|
@@ -961,6 +1325,24 @@ export const performDetach = async ({
|
|
|
961
1325
|
return { backend: backendResult, localCleaned: true };
|
|
962
1326
|
};
|
|
963
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* Every line a seat emits lands in one file, because the fleet is launched as
|
|
1330
|
+
* `nohup commonly agent run <name> > <log> 2>&1`. Until now none of those lines
|
|
1331
|
+
* carried a time.
|
|
1332
|
+
*
|
|
1333
|
+
* That is not a cosmetic gap. On 2026-08-18 a fleet-wide quota stall destroyed
|
|
1334
|
+
* 38 queued events (#993), and the seat log was the ONLY surviving trace —
|
|
1335
|
+
* the kernel rows were deleted, so nothing else recorded that those turns had
|
|
1336
|
+
* been attempted. The log records `(4 consecutive)` and `next probe in 2.0m`
|
|
1337
|
+
* and gives no way to place either on a clock: the investigation had to date
|
|
1338
|
+
* events by decoding ObjectId prefixes instead, because the file that named
|
|
1339
|
+
* them could not say when.
|
|
1340
|
+
*
|
|
1341
|
+
* ISO-8601 so stamps sort lexically, diff cleanly, and line up with the
|
|
1342
|
+
* kernel's own timestamps without conversion.
|
|
1343
|
+
*/
|
|
1344
|
+
const stamp = () => new Date().toISOString();
|
|
1345
|
+
|
|
964
1346
|
export const registerAgent = (program) => {
|
|
965
1347
|
const agent = program.command('agent').description('Manage agents');
|
|
966
1348
|
|
|
@@ -1300,11 +1682,33 @@ Docs:
|
|
|
1300
1682
|
.command('run <name>')
|
|
1301
1683
|
.description('Run the local-CLI wrapper loop for an attached agent')
|
|
1302
1684
|
.option('--interval <ms>', 'Poll interval in ms', '5000')
|
|
1685
|
+
.option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex); ignored when a token file already exists')
|
|
1303
1686
|
.action(async (name, opts) => {
|
|
1304
|
-
|
|
1687
|
+
let record = loadAgentToken(name);
|
|
1305
1688
|
if (!record) {
|
|
1306
|
-
|
|
1307
|
-
|
|
1689
|
+
// First run on this machine: the BYO connect page hands out env vars,
|
|
1690
|
+
// not a token file — bootstrap the record from them (#913).
|
|
1691
|
+
try {
|
|
1692
|
+
record = await bootstrapAgentRecordFromEnv({
|
|
1693
|
+
name,
|
|
1694
|
+
adapterOverride: opts.adapter || null,
|
|
1695
|
+
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
|
1696
|
+
});
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
console.error(`${stamp()} [${name}] ${err.message}`);
|
|
1699
|
+
process.exit(1);
|
|
1700
|
+
}
|
|
1701
|
+
if (record) {
|
|
1702
|
+
saveAgentToken(record.agentName, record);
|
|
1703
|
+
console.log(`${stamp()} [${name}] bootstrapped ${tokenFile(record.agentName)} from COMMONLY_AGENT_TOKEN (adapter: ${record.adapter})`);
|
|
1704
|
+
} else {
|
|
1705
|
+
console.error(
|
|
1706
|
+
`No token for '${name}'. Either export COMMONLY_API_URL + COMMONLY_AGENT_TOKEN`
|
|
1707
|
+
+ ` (shown on the Connect your own agent page) and re-run, or:`
|
|
1708
|
+
+ ` commonly agent attach <adapter> --pod <podId> --name ${name}`,
|
|
1709
|
+
);
|
|
1710
|
+
process.exit(1);
|
|
1711
|
+
}
|
|
1308
1712
|
}
|
|
1309
1713
|
|
|
1310
1714
|
const adapter = getAdapter(record.adapter);
|
|
@@ -1313,7 +1717,14 @@ Docs:
|
|
|
1313
1717
|
process.exit(1);
|
|
1314
1718
|
}
|
|
1315
1719
|
|
|
1316
|
-
|
|
1720
|
+
// THE line to stamp, not just one of them. It is the first line of every
|
|
1721
|
+
// seat log and the truncation boundary — the fleet is launched as
|
|
1722
|
+
// `nohup … > log 2>&1`, so this banner is written at boot and everything
|
|
1723
|
+
// before it is gone. Stamped, it dates the restart from the log itself.
|
|
1724
|
+
// Unstamped, it took `ps -o lstart` to establish when nine seats came
|
|
1725
|
+
// back on 2026-08-18, and that only worked because the processes were
|
|
1726
|
+
// still alive — after the next restart that route is gone too.
|
|
1727
|
+
console.log(`${stamp()} [${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
|
|
1317
1728
|
|
|
1318
1729
|
const { stop } = performRun({
|
|
1319
1730
|
instanceUrl: record.instanceUrl,
|
|
@@ -1325,12 +1736,32 @@ Docs:
|
|
|
1325
1736
|
environment: record.environment || null,
|
|
1326
1737
|
workspacePath: record.workspacePath || null,
|
|
1327
1738
|
intervalMs: parseInt(opts.interval, 10),
|
|
1328
|
-
log: (line) => console.log(
|
|
1329
|
-
|
|
1739
|
+
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
|
1740
|
+
// Both sinks are stamped, and both must be — but not for the reason
|
|
1741
|
+
// this comment gave until now, which its own PR falsified.
|
|
1742
|
+
//
|
|
1743
|
+
// A spawn failure is routed to `onError` when a caller provides one and
|
|
1744
|
+
// to `log` only as the fallback, so in `agent run` — which always passes
|
|
1745
|
+
// one — EVERY failure line comes out of the error channel and none out
|
|
1746
|
+
// of the log. Stamping only `log:` would leave the entire failure class
|
|
1747
|
+
// undated, which is the class these stamps exist for.
|
|
1748
|
+
//
|
|
1749
|
+
// The two sinks also carry genuinely different text elsewhere: the
|
|
1750
|
+
// no-prompt skip logs a terse line and sends a detailed diagnostic, so
|
|
1751
|
+
// one stamped and one bare would date half of that pair.
|
|
1752
|
+
//
|
|
1753
|
+
// (History, because the reasoning moved twice. This originally argued
|
|
1754
|
+
// from a DOUBLE emission — the retry path called both sinks with
|
|
1755
|
+
// identical text, so `grep -c 'session limit'` returned 8 for 4 failures
|
|
1756
|
+
// and a per-event count read as two deliveries. That was true when the
|
|
1757
|
+
// stamps landed and false eleven minutes later, when the same PR
|
|
1758
|
+
// collapsed the duplication. The conclusion survived the premise; the
|
|
1759
|
+
// sentence did not, and nothing in the diff pointed at it.)
|
|
1760
|
+
onError: (err) => console.error(`${stamp()} [${name}] ${err.message}`),
|
|
1330
1761
|
});
|
|
1331
1762
|
|
|
1332
1763
|
process.on('SIGINT', () => {
|
|
1333
|
-
console.log(`\n[${name}] stopping...`);
|
|
1764
|
+
console.log(`\n${stamp()} [${name}] stopping...`);
|
|
1334
1765
|
stop();
|
|
1335
1766
|
process.exit(0);
|
|
1336
1767
|
});
|
|
@@ -474,7 +474,18 @@ export default {
|
|
|
474
474
|
const sessionId = ctx.sessionId || randomUUID();
|
|
475
475
|
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
|
|
476
476
|
const sessionFlag = isResume ? '--resume' : '--session-id';
|
|
477
|
-
|
|
477
|
+
// Model pin from the ADR-008 environment spec. Absent it, claude picks its
|
|
478
|
+
// own default — which is how a fleet of ten agents ended up running three
|
|
479
|
+
// different Opus versions that nobody chose and nothing recorded, with one
|
|
480
|
+
// seat named after a model it does not run.
|
|
481
|
+
//
|
|
482
|
+
// Computed ONCE and spread into BOTH arg builders. The session-recovery
|
|
483
|
+
// path at the bottom of this function constructs its own array, and a model
|
|
484
|
+
// present in one but not the other means a retry silently runs a different
|
|
485
|
+
// model than the turn it is replacing — the same drifting-copy shape that
|
|
486
|
+
// has bitten this codebase repeatedly.
|
|
487
|
+
const modelArgs = ctx.environment?.model ? ['--model', String(ctx.environment.model)] : [];
|
|
488
|
+
const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId, ...modelArgs];
|
|
478
489
|
|
|
479
490
|
if (ctx.environment && ctx.cwd) {
|
|
480
491
|
const skills = await mountSkills(ctx.environment, ctx.cwd);
|
|
@@ -529,7 +540,7 @@ export default {
|
|
|
529
540
|
// session id poisons every subsequent event re-delivery.
|
|
530
541
|
if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
|
|
531
542
|
const freshId = randomUUID();
|
|
532
|
-
const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId];
|
|
543
|
+
const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
|
|
533
544
|
const retry = await prepareArgv(retryBase, {
|
|
534
545
|
...ctx,
|
|
535
546
|
mcpConfigPath: mcpConfig?.file || null,
|
package/src/lib/api.js
CHANGED
|
@@ -48,7 +48,30 @@ export const createClient = ({ instance = null, token = null } = {}) => {
|
|
|
48
48
|
headers: headers(authToken),
|
|
49
49
|
}).then(handleResponse);
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Multipart upload via native FormData/Blob (Node 18+) — no runtime deps.
|
|
52
|
+
// Content-Type is deliberately NOT set: fetch writes the multipart boundary.
|
|
53
|
+
const upload = (path, {
|
|
54
|
+
fileBuffer, fileName, contentType, fileField = 'file', fields = {},
|
|
55
|
+
}) => {
|
|
56
|
+
const form = new FormData();
|
|
57
|
+
form.append(
|
|
58
|
+
fileField,
|
|
59
|
+
new Blob([fileBuffer], { type: contentType || 'application/octet-stream' }),
|
|
60
|
+
fileName,
|
|
61
|
+
);
|
|
62
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
63
|
+
if (v !== undefined && v !== null) form.append(k, String(v));
|
|
64
|
+
}
|
|
65
|
+
return fetch(`${baseUrl}${path}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
|
68
|
+
body: form,
|
|
69
|
+
}).then(handleResponse);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
get, post, del, upload, baseUrl,
|
|
74
|
+
};
|
|
52
75
|
};
|
|
53
76
|
|
|
54
77
|
// Convenience: login doesn't need a token
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper-side enforcement (ADR-018 D3, "our drivers" row).
|
|
3
|
+
*
|
|
4
|
+
* The 2026-08-11 pilot proved that advisory guidance loses to task gravity:
|
|
5
|
+
* the tone contract was in every seat's tool descriptions and the review
|
|
6
|
+
* median still came out at 3,614 characters, with zero claims taken and a
|
|
7
|
+
* mention cascade that needed a manual wrapper kill. This module is the
|
|
8
|
+
* deterministic half — the wrapper enforces what the contract can only ask:
|
|
9
|
+
*
|
|
10
|
+
* - claim-before-act + stand-down (createClaimKeeper)
|
|
11
|
+
* - per-seat cascade damping (createCascadeGovernor, classifyTrigger)
|
|
12
|
+
* - post-time length gate (splitForChat, deliverChatReply)
|
|
13
|
+
*
|
|
14
|
+
* One rule overrides everything here: enforcement must never convert an
|
|
15
|
+
* infrastructure failure into agent silence (#887 class). Every network or
|
|
16
|
+
* server error fails OPEN — the turn proceeds unguarded and says so in the
|
|
17
|
+
* log. Only an explicit "someone else holds it" or "cascade cap reached"
|
|
18
|
+
* stands the agent down, and both are logged with the reason.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Event types whose payload.messageId identifies a claimable trigger message.
|
|
22
|
+
// first_contact is deliberately absent: the welcome wake targets exactly one
|
|
23
|
+
// agent, so there is nothing to contend for. Heartbeats have no message at
|
|
24
|
+
// all, and agent.ask routes privately.
|
|
25
|
+
export const CLAIMABLE_EVENT_TYPES = new Set([
|
|
26
|
+
'chat.mention',
|
|
27
|
+
'message.posted',
|
|
28
|
+
'dm.message',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// ── trigger classification ──────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Who authored the message that woke us — 'agent', 'human', or 'unknown'?
|
|
35
|
+
*
|
|
36
|
+
* Two signals, in order of reliability:
|
|
37
|
+
* 1. payload.dmKind — the kernel stamps DM wakes 'agent-agent'/'user-agent'.
|
|
38
|
+
* 2. The trigger message's isBot flag, looked up by payload.messageId in the
|
|
39
|
+
* pre-spawn snapshot the run loop already fetches for echo suppression.
|
|
40
|
+
*
|
|
41
|
+
* 'unknown' is the fail-open verdict: it neither counts toward the cascade
|
|
42
|
+
* cap nor resets it. In a live cascade the trigger message is seconds old and
|
|
43
|
+
* always inside the snapshot window, so cascades classify reliably; a message
|
|
44
|
+
* that has already scrolled out of the window is not cascade tempo.
|
|
45
|
+
*/
|
|
46
|
+
export const classifyTrigger = (event, recentMessages) => {
|
|
47
|
+
const p = event?.payload || {};
|
|
48
|
+
if (p.dmKind === 'agent-agent') return 'agent';
|
|
49
|
+
if (p.dmKind === 'user-agent') return 'human';
|
|
50
|
+
if (!p.messageId || !Array.isArray(recentMessages)) return 'unknown';
|
|
51
|
+
const trigger = recentMessages.find(
|
|
52
|
+
(m) => String(m._id || m.id) === String(p.messageId),
|
|
53
|
+
);
|
|
54
|
+
if (!trigger || typeof trigger.isBot !== 'boolean') return 'unknown';
|
|
55
|
+
return trigger.isBot ? 'agent' : 'human';
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Direct-address event types: the seat was NAMED (explicit @, implicit human
|
|
59
|
+
// reply, or DM routing). A lost claim on these does not silence the seat —
|
|
60
|
+
// being chosen by a human outranks being beaten to a CAS. Broadcast wakes
|
|
61
|
+
// (message.posted) are the opposite: nobody asked for THIS seat, so a lost
|
|
62
|
+
// race is a free stand-down.
|
|
63
|
+
export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
|
|
64
|
+
|
|
65
|
+
// ── cascade governor ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per-pod damping of agent→agent retrigger chains.
|
|
69
|
+
*
|
|
70
|
+
* The pilot's failure shape: an agent's own posts kept waking it (via another
|
|
71
|
+
* agent's replies) until the operator killed the wrapper at round 3. The
|
|
72
|
+
* governor counts CONSECUTIVE agent-triggered turns per pod; at `cap` it
|
|
73
|
+
* 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).
|
|
77
|
+
*
|
|
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.
|
|
83
|
+
*
|
|
84
|
+
* Split into admit/record so a spawn that fails (and will be redelivered)
|
|
85
|
+
* never double-counts: admit() only reads, record() runs after a turn
|
|
86
|
+
* actually completed. Human-triggered turns are always admitted.
|
|
87
|
+
*/
|
|
88
|
+
export const createCascadeGovernor = ({
|
|
89
|
+
cap = 3,
|
|
90
|
+
addressedGrace = 2,
|
|
91
|
+
resetMs = 10 * 60 * 1000,
|
|
92
|
+
now = Date.now,
|
|
93
|
+
} = {}) => {
|
|
94
|
+
const pods = new Map(); // podId -> { streak, lastAgentTurnAt }
|
|
95
|
+
|
|
96
|
+
const stateFor = (podId) => {
|
|
97
|
+
const s = pods.get(podId) || { streak: 0, lastAgentTurnAt: 0 };
|
|
98
|
+
if (s.streak > 0 && now() - s.lastAgentTurnAt > resetMs) {
|
|
99
|
+
return { streak: 0, lastAgentTurnAt: 0 };
|
|
100
|
+
}
|
|
101
|
+
return s;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
admit(podId, trigger, eventType) {
|
|
106
|
+
if (trigger !== 'agent') return { allowed: true, streak: 0, addressed: false };
|
|
107
|
+
const s = stateFor(podId);
|
|
108
|
+
// Being NAMED outranks a mechanical brake — the same judgement the claim
|
|
109
|
+
// path already makes forty lines down in agent.js. Without this, a peer
|
|
110
|
+
// can @mention a capped seat and get silence, with no signal to either
|
|
111
|
+
// side that anything was suppressed. Observed 2026-08-18: one seat took
|
|
112
|
+
// 51 wakes and 28 consecutive cap refusals, five of them chat.mention,
|
|
113
|
+
// and answered none of them.
|
|
114
|
+
//
|
|
115
|
+
// A GRACE, not an exemption: an unbounded pass would restore the exact
|
|
116
|
+
// A-mentions-B-mentions-A echo this governor exists to kill. Addressed
|
|
117
|
+
// turns still count toward the streak, so a mention loop terminates at
|
|
118
|
+
// cap + addressedGrace instead of never.
|
|
119
|
+
const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
|
|
120
|
+
const limit = addressed ? cap + addressedGrace : cap;
|
|
121
|
+
return { allowed: s.streak < limit, streak: s.streak, addressed };
|
|
122
|
+
},
|
|
123
|
+
record(podId, trigger) {
|
|
124
|
+
if (trigger === 'human') {
|
|
125
|
+
pods.set(podId, { streak: 0, lastAgentTurnAt: 0 });
|
|
126
|
+
} else if (trigger === 'agent') {
|
|
127
|
+
const s = stateFor(podId);
|
|
128
|
+
pods.set(podId, { streak: s.streak + 1, lastAgentTurnAt: now() });
|
|
129
|
+
}
|
|
130
|
+
// 'unknown' is neutral: no count, no reset.
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ── claim fairness ──────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
// Frame prepended when an ADDRESSED seat lost the claim race: it still gets
|
|
139
|
+
// its turn, but knows a peer is (probably) already answering — the bar for
|
|
140
|
+
// posting rises from "have something to say" to "have something DIFFERENT".
|
|
141
|
+
export const peerHoldsFrame = (holder, messageId) => (
|
|
142
|
+
`[Claim notice: @${holder} holds message ${messageId} and is likely responding. `
|
|
143
|
+
+ 'You were directly addressed, so you still get this turn — but add your view ONLY '
|
|
144
|
+
+ 'if it is materially different from what they would cover; otherwise return NO_REPLY.]'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Win-weighted claim delay — the "cooldown" that keeps one fast seat from
|
|
149
|
+
* monopolising a pod's broadcast wakes without ever risking that NOBODY
|
|
150
|
+
* claims. A seat that won its previous broadcast race in a pod waits a small
|
|
151
|
+
* jittered delay before entering the next one; everyone still claims, recent
|
|
152
|
+
* winners just start from the back. A loss (or `windowMs` of quiet) clears
|
|
153
|
+
* the handicap — you are only the monopolist while you are actually winning.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately NOT a hard cooldown: with all seats abstaining, a message
|
|
156
|
+
* goes unhandled — the #887 shape again, self-inflicted.
|
|
157
|
+
*/
|
|
158
|
+
export const createClaimHandicap = ({
|
|
159
|
+
delayMs = 3000,
|
|
160
|
+
jitterMs = 1000,
|
|
161
|
+
windowMs = 5 * 60 * 1000,
|
|
162
|
+
now = Date.now,
|
|
163
|
+
random = Math.random,
|
|
164
|
+
} = {}) => {
|
|
165
|
+
const wins = new Map(); // podId -> lastBroadcastWinAt
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
recordWin(podId) {
|
|
169
|
+
wins.set(podId, now());
|
|
170
|
+
},
|
|
171
|
+
recordLoss(podId) {
|
|
172
|
+
wins.delete(podId);
|
|
173
|
+
},
|
|
174
|
+
yieldDelayMs(podId) {
|
|
175
|
+
const at = wins.get(podId);
|
|
176
|
+
// Explicit undefined check: a win recorded at clock 0 is still a win
|
|
177
|
+
// (a falsy-timestamp `!at` here silently disabled the handicap for the
|
|
178
|
+
// first test clock tick — caught by the unit suite).
|
|
179
|
+
if (at === undefined || now() - at > windowMs) return 0;
|
|
180
|
+
return delayMs + Math.floor(random() * jitterMs);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// ── claim keeper ────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* One claim lifecycle for one event: acquire → renew while the CLI turn runs
|
|
189
|
+
* → release (or discover mid-turn loss and stand down at post time).
|
|
190
|
+
*
|
|
191
|
+
* Acquire outcomes:
|
|
192
|
+
* { claimed: true } — we hold the lease; start renewal.
|
|
193
|
+
* { claimed: false, holder } — someone else holds it; STAND DOWN.
|
|
194
|
+
* { claimed: false, failOpen: true } — claim route unavailable (older
|
|
195
|
+
* server, network, 403 on a stale install); proceed UNGUARDED. The
|
|
196
|
+
* alternative turns a kernel deploy gap into a silent agent.
|
|
197
|
+
*
|
|
198
|
+
* Renewal reuses the same POST (a holder wins against itself — that IS
|
|
199
|
+
* renewal, per messageClaimService). A renewal that comes back claimed:false
|
|
200
|
+
* means our lease lapsed (laptop slept, turn ran long) and a peer re-won:
|
|
201
|
+
* mark lost so the run loop suppresses the wrapper post. Transient renewal
|
|
202
|
+
* errors are ignored — the current lease may still be live, and the next
|
|
203
|
+
* tick retries.
|
|
204
|
+
*/
|
|
205
|
+
export const createClaimKeeper = (client, {
|
|
206
|
+
messageId,
|
|
207
|
+
podId,
|
|
208
|
+
leaseSeconds = 90,
|
|
209
|
+
log = () => {},
|
|
210
|
+
setIntervalImpl = setInterval,
|
|
211
|
+
clearIntervalImpl = clearInterval,
|
|
212
|
+
}) => {
|
|
213
|
+
const path = `/api/agents/runtime/messages/${encodeURIComponent(messageId)}/claim`;
|
|
214
|
+
let acquired = false;
|
|
215
|
+
let lost = false;
|
|
216
|
+
let holder = null;
|
|
217
|
+
let timer = null;
|
|
218
|
+
|
|
219
|
+
const holderLabel = (res) => {
|
|
220
|
+
if (!res?.claimedBy) return 'another agent';
|
|
221
|
+
const instance = res.instanceId && res.instanceId !== 'default' ? `:${res.instanceId}` : '';
|
|
222
|
+
return `${res.claimedBy}${instance}`;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const stopRenewal = () => {
|
|
226
|
+
if (timer) {
|
|
227
|
+
clearIntervalImpl(timer);
|
|
228
|
+
timer = null;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
async acquire() {
|
|
234
|
+
try {
|
|
235
|
+
const res = await client.post(path, { podId, leaseSeconds });
|
|
236
|
+
if (res?.claimed) {
|
|
237
|
+
acquired = true;
|
|
238
|
+
return { claimed: true, expiresAt: res.expiresAt };
|
|
239
|
+
}
|
|
240
|
+
holder = holderLabel(res);
|
|
241
|
+
return { claimed: false, holder };
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return { claimed: false, failOpen: true, error: err };
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
startRenewal() {
|
|
248
|
+
if (!acquired || timer) return;
|
|
249
|
+
timer = setIntervalImpl(async () => {
|
|
250
|
+
try {
|
|
251
|
+
const res = await client.post(path, { podId, leaseSeconds });
|
|
252
|
+
if (!res?.claimed) {
|
|
253
|
+
lost = true;
|
|
254
|
+
holder = holderLabel(res);
|
|
255
|
+
stopRenewal();
|
|
256
|
+
log(`claim on message ${messageId} lost mid-turn to ${holder} — standing down at post time`);
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// Transient renewal failure — the held lease may still be live;
|
|
260
|
+
// retry on the next tick rather than standing down on a blip.
|
|
261
|
+
}
|
|
262
|
+
}, Math.max(5000, (leaseSeconds * 1000) / 2));
|
|
263
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async release() {
|
|
267
|
+
stopRenewal();
|
|
268
|
+
if (!acquired || lost) return;
|
|
269
|
+
try {
|
|
270
|
+
await client.del(path);
|
|
271
|
+
} catch {
|
|
272
|
+
// Best-effort: a miss just means the lease already expired.
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
isLost: () => lost,
|
|
277
|
+
getHolder: () => holder,
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// ── post-time length gate ───────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Split chat text into tone-contract-sized messages without ever cutting
|
|
285
|
+
* content. Boundaries in preference order: fenced code blocks stay whole
|
|
286
|
+
* (atomic — an oversized fence becomes one oversized message rather than a
|
|
287
|
+
* broken pair), then paragraphs, then sentences, then words. Greedy packing
|
|
288
|
+
* rejoins small pieces so two short paragraphs share one message.
|
|
289
|
+
*/
|
|
290
|
+
export const splitForChat = (text, { limit = 400 } = {}) => {
|
|
291
|
+
const trimmed = String(text || '').trim();
|
|
292
|
+
if (!trimmed) return [];
|
|
293
|
+
if (trimmed.length <= limit) return [trimmed];
|
|
294
|
+
|
|
295
|
+
// Pass 1: carve into blocks — fences atomic, prose split by paragraph.
|
|
296
|
+
const blocks = [];
|
|
297
|
+
let prose = [];
|
|
298
|
+
const flushProse = () => {
|
|
299
|
+
const joined = prose.join('\n');
|
|
300
|
+
prose = [];
|
|
301
|
+
for (const para of joined.split(/\n{2,}/)) {
|
|
302
|
+
if (para.trim()) blocks.push({ text: para.trim(), atomic: false });
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const lines = trimmed.split('\n');
|
|
306
|
+
let i = 0;
|
|
307
|
+
while (i < lines.length) {
|
|
308
|
+
const fence = lines[i].match(/^(```|~~~)/)?.[1];
|
|
309
|
+
if (fence) {
|
|
310
|
+
flushProse();
|
|
311
|
+
const fenced = [lines[i]];
|
|
312
|
+
i += 1;
|
|
313
|
+
while (i < lines.length && !lines[i].startsWith(fence)) {
|
|
314
|
+
fenced.push(lines[i]);
|
|
315
|
+
i += 1;
|
|
316
|
+
}
|
|
317
|
+
if (i < lines.length) {
|
|
318
|
+
fenced.push(lines[i]); // closing fence
|
|
319
|
+
i += 1;
|
|
320
|
+
}
|
|
321
|
+
blocks.push({ text: fenced.join('\n'), atomic: true });
|
|
322
|
+
} else {
|
|
323
|
+
prose.push(lines[i]);
|
|
324
|
+
i += 1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
flushProse();
|
|
328
|
+
|
|
329
|
+
// Pass 2: split oversized prose blocks at sentence then word boundaries.
|
|
330
|
+
const units = [];
|
|
331
|
+
for (const block of blocks) {
|
|
332
|
+
if (block.atomic || block.text.length <= limit) {
|
|
333
|
+
units.push(block.text);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
let piece = '';
|
|
337
|
+
const flushPiece = () => {
|
|
338
|
+
if (piece.trim()) units.push(piece.trim());
|
|
339
|
+
piece = '';
|
|
340
|
+
};
|
|
341
|
+
for (const sentence of block.text.split(/(?<=[.!?])\s+/)) {
|
|
342
|
+
if (sentence.length > limit) {
|
|
343
|
+
flushPiece();
|
|
344
|
+
let run = '';
|
|
345
|
+
for (const word of sentence.split(/\s+/)) {
|
|
346
|
+
if (run && `${run} ${word}`.length > limit) {
|
|
347
|
+
units.push(run);
|
|
348
|
+
run = word;
|
|
349
|
+
} else {
|
|
350
|
+
run = run ? `${run} ${word}` : word;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (run) units.push(run); // a single over-limit word (URL) posts whole
|
|
354
|
+
} else if (piece && `${piece} ${sentence}`.length > limit) {
|
|
355
|
+
flushPiece();
|
|
356
|
+
piece = sentence;
|
|
357
|
+
} else {
|
|
358
|
+
piece = piece ? `${piece} ${sentence}` : sentence;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
flushPiece();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Pass 3: greedy packing back up to the limit.
|
|
365
|
+
const chunks = [];
|
|
366
|
+
let acc = '';
|
|
367
|
+
for (const unit of units) {
|
|
368
|
+
const joined = acc ? `${acc}\n\n${unit}` : unit;
|
|
369
|
+
if (joined.length <= limit) {
|
|
370
|
+
acc = joined;
|
|
371
|
+
} else {
|
|
372
|
+
if (acc) chunks.push(acc);
|
|
373
|
+
acc = unit;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (acc) chunks.push(acc);
|
|
377
|
+
return chunks;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Deliver a wrapper-posted reply under the tone contract, deterministically:
|
|
382
|
+
*
|
|
383
|
+
* fits in one message → post as-is
|
|
384
|
+
* splits into ≤ maxChunks → post the chunks in order ("two short
|
|
385
|
+
* messages beat one wall")
|
|
386
|
+
* longer than a split answer → it is a document, not a message: upload
|
|
387
|
+
* the FULL text as a file and post one
|
|
388
|
+
* message — the reply's own opening plus
|
|
389
|
+
* the file card. Nothing is cut; the file
|
|
390
|
+
* holds everything.
|
|
391
|
+
*
|
|
392
|
+
* If the upload fails (older server, network), fall back to posting every
|
|
393
|
+
* chunk: a message flood is a tone violation, silence or truncation is a
|
|
394
|
+
* correctness violation, and the contract itself ranks content above tone
|
|
395
|
+
* ("NEVER hit that by cutting content").
|
|
396
|
+
*/
|
|
397
|
+
export const deliverChatReply = async ({
|
|
398
|
+
client,
|
|
399
|
+
podId,
|
|
400
|
+
text,
|
|
401
|
+
limit = 400,
|
|
402
|
+
maxChunks = 3,
|
|
403
|
+
attachThreshold = 800,
|
|
404
|
+
uploadName = 'reply.md',
|
|
405
|
+
log = () => {},
|
|
406
|
+
}) => {
|
|
407
|
+
const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
|
|
408
|
+
const chunks = splitForChat(text, { limit });
|
|
409
|
+
// An atomic unit (a fenced block, an unbreakable word-run) can exceed the
|
|
410
|
+
// limit by construction — splitForChat keeps it whole rather than breaking
|
|
411
|
+
// its rendering. The tone contract's own rule covers it: over ~800 chars of
|
|
412
|
+
// ONE indivisible thing is a document, not a message — attach it. Without
|
|
413
|
+
// this check a 659-char fence rode the single/split branches straight past
|
|
414
|
+
// the gate (found by the fleet's implementation audit, Sharpen msg 53018).
|
|
415
|
+
const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
|
|
416
|
+
if (chunks.length <= 1 && !hasIndivisibleOversize) {
|
|
417
|
+
await client.post(messagesPath, { content: chunks[0] ?? text });
|
|
418
|
+
return { mode: 'single', messages: 1 };
|
|
419
|
+
}
|
|
420
|
+
if (chunks.length <= maxChunks && !hasIndivisibleOversize) {
|
|
421
|
+
for (const chunk of chunks) {
|
|
422
|
+
// eslint-disable-next-line no-await-in-loop
|
|
423
|
+
await client.post(messagesPath, { content: chunk }); // in order, so the reply reads top-down
|
|
424
|
+
}
|
|
425
|
+
return { mode: 'split', messages: chunks.length };
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const uploaded = await client.upload(`/api/agents/runtime/pods/${podId}/uploads`, {
|
|
429
|
+
fileBuffer: Buffer.from(String(text), 'utf8'),
|
|
430
|
+
fileName: uploadName,
|
|
431
|
+
contentType: 'text/markdown',
|
|
432
|
+
fields: { podId },
|
|
433
|
+
});
|
|
434
|
+
const u = uploaded || {};
|
|
435
|
+
const directive = `[[upload:${u.fileName || uploadName}|${u.originalName || uploadName}|${u.size ?? Buffer.byteLength(String(text))}|${u.kind || 'document'}]]`;
|
|
436
|
+
// Lead with the reply's own opening — unless that opening is itself the
|
|
437
|
+
// oversized atomic unit (a fence-only reply), in which case a generic
|
|
438
|
+
// line keeps the message under the gate and the card carries the content.
|
|
439
|
+
const lead = chunks[0] && chunks[0].length <= limit
|
|
440
|
+
? chunks[0]
|
|
441
|
+
: '(reply too large for chat — attached in full)';
|
|
442
|
+
await client.post(messagesPath, { content: `${lead}\n\n${directive}` });
|
|
443
|
+
return { mode: 'attach', messages: 1 };
|
|
444
|
+
} catch (err) {
|
|
445
|
+
log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`);
|
|
446
|
+
for (const chunk of chunks) {
|
|
447
|
+
// eslint-disable-next-line no-await-in-loop
|
|
448
|
+
await client.post(messagesPath, { content: chunk });
|
|
449
|
+
}
|
|
450
|
+
return { mode: 'split-fallback', messages: chunks.length };
|
|
451
|
+
}
|
|
452
|
+
};
|
package/src/lib/environment.js
CHANGED
|
@@ -29,8 +29,19 @@ import { homedir } from 'os';
|
|
|
29
29
|
// exposes `$HOME` layout for zero server-side benefit. Callers pass
|
|
30
30
|
// `envFileDir` as a separate argument to resolveWorkspace / mountSkills.
|
|
31
31
|
|
|
32
|
+
// `model` closes issue #774. Before it, the only way to pin a wrapper's model
|
|
33
|
+
// was `ANTHROPIC_MODEL` exported at `commonly agent run` time — per-process,
|
|
34
|
+
// never persisted, and silently dropped by any restart. Measured consequence on
|
|
35
|
+
// 2026-08-16: six of nine live seats had lost their assigned model, including
|
|
36
|
+
// ux-lead, which was supposed to be on Fable and had been running the CLI
|
|
37
|
+
// default for days with nothing recording the drift.
|
|
38
|
+
//
|
|
39
|
+
// Putting it in the env spec makes the model a persisted, server-side fact that
|
|
40
|
+
// survives restarts and is readable by the platform — which is what ADR-022's
|
|
41
|
+
// "persona and runtime are chosen separately" requires to mean anything for a
|
|
42
|
+
// BYO seat, and what lets an identity card answer "what is this running".
|
|
32
43
|
const ALLOWED_TOP_KEYS = new Set([
|
|
33
|
-
'version', 'workspace', 'sandbox', 'skills', 'mcp',
|
|
44
|
+
'version', 'workspace', 'sandbox', 'skills', 'mcp', 'model',
|
|
34
45
|
]);
|
|
35
46
|
const ALLOWED_SANDBOX_MODES = new Set([
|
|
36
47
|
'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
|
|
@@ -112,6 +123,18 @@ export const validateEnvironmentSpec = (spec) => {
|
|
|
112
123
|
errors.push(`version must be 1, got ${JSON.stringify(spec.version)}`);
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
// Validated as an opaque non-empty string, deliberately not against a list of
|
|
127
|
+
// known model ids. A whitelist here would need editing every time a provider
|
|
128
|
+
// ships a model, and would reject a valid id the local CLI understands and
|
|
129
|
+
// this file does not — failing the user's attach for a fact it is not the
|
|
130
|
+
// authority on. The adapter passes it through to `--model`; the CLI is the
|
|
131
|
+
// thing that knows what is valid, and its error is the honest one.
|
|
132
|
+
if (spec.model !== undefined) {
|
|
133
|
+
if (typeof spec.model !== 'string' || spec.model.trim() === '') {
|
|
134
|
+
errors.push('model must be a non-empty string');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
if (spec.workspace !== undefined) {
|
|
116
139
|
if (typeof spec.workspace !== 'object' || spec.workspace === null) {
|
|
117
140
|
errors.push('workspace must be an object');
|
package/src/lib/spawn-retry.js
CHANGED
|
@@ -19,11 +19,37 @@ export const SPAWN_CIRCUIT_THRESHOLD = 3;
|
|
|
19
19
|
export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
|
|
20
20
|
export const SPAWN_RETRY_JITTER_MAX_RATIO = 0.2;
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
22
|
+
// This list is a per-provider allowlist, and it only ever grows after an
|
|
23
|
+
// outage has already been misclassified. Twice now:
|
|
24
|
+
// 2026-08-03 codex "Your workspace is out of credits." → `out of credits`
|
|
25
|
+
// 2026-08-18 claude "You've hit your session limit" → `session limit`
|
|
26
|
+
// The second one is the instructive failure: `usage limit` was already here —
|
|
27
|
+
// it is Claude's OTHER exhaustion wording — so the fleet stalled for an hour on
|
|
28
|
+
// a string one word away from a pattern we had. Both times the miss meant
|
|
29
|
+
// RUNTIME, the weakest class with the shortest backoff, against a provider that
|
|
30
|
+
// was not going to answer for hours.
|
|
31
|
+
//
|
|
32
|
+
// What a miss costs, measured against these constants (intervalMs 5000,
|
|
33
|
+
// jitter 0; `*` = circuit open):
|
|
34
|
+
//
|
|
35
|
+
// quota n=1,2,3 -> 900s* 900s* 900s*
|
|
36
|
+
// configuration n=1,2,3 -> 900s* 900s* 900s*
|
|
37
|
+
// rate_limit n=1,2,3 -> 60s* 120s* 240s*
|
|
38
|
+
// runtime n=1,2,3 -> 5s 10s 60s*
|
|
39
|
+
//
|
|
40
|
+
// So an unmatched wording is not one class off — it is 180x faster on the
|
|
41
|
+
// first retry than the class it belonged in, and the only class that does not
|
|
42
|
+
// open the circuit at n=1. That is the price of a missing string, and it is
|
|
43
|
+
// why the entry below is a list of exact wordings rather than a loose pattern.
|
|
44
|
+
//
|
|
45
|
+
// Note RUNTIME is also `classifySpawnFailure`'s fallthrough, so "unrecognised"
|
|
46
|
+
// and "transient local fault" resolve to the same, most aggressive schedule.
|
|
47
|
+
// That is a structural issue rather than a vocabulary one — see #996.
|
|
48
|
+
//
|
|
49
|
+
// Deliberately NOT loosened to a bare `limit`: QUOTA is tested before
|
|
50
|
+
// RATE_LIMIT, so that would swallow every "rate limit" error into the 15-minute
|
|
51
|
+
// cooldown. Add exact wordings, not looser ones.
|
|
52
|
+
const QUOTA_RE = /(?:quota|usage limit|session limit|credit balance|out of credits|billing|insufficient[_ -]?quota|resource exhausted|spending limit)/i;
|
|
27
53
|
const RATE_LIMIT_RE = /(?:rate[ -]?limit|too many requests|\b429\b|overloaded|capacity)/i;
|
|
28
54
|
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
55
|
|