import { createOwnKeyResolver, createMachineReportClient } from '../lib/own-key-launch.js'; import { WALKED_HARNESSES } from '../lib/runtime-inventory.js'; import { createRuntimeReporter } from '../lib/runtime-observation.js'; /** * commonly agent * * register — register a webhook agent against an instance * connect — local dev loop: poll events → forward to localhost * attach — wrap a local CLI as a Commonly agent (ADR-005) * run — poll events, spawn the wrapped CLI, post results (ADR-005) * init — scaffold a webhook-SDK agent in the current dir (ADR-006) * list — list installed agents * logs — stream recent events for an agent * heartbeat — manually trigger an agent's heartbeat */ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'fs'; import { join, dirname, basename, resolve as pathResolve } from 'path'; import { homedir, tmpdir } from 'os'; import { fileURLToPath } from 'url'; import { createHash } from 'crypto'; import { requireSeatModel } from '../lib/adapters/seat-model.js'; import { createClient } from '../lib/api.js'; import { buildSpawnId, createSpawnCredentialLease, readSpawnPolicy, resolveSpawnTtlSeconds, revokeOrphanedSpawnCredentials, } from '../lib/spawn-credential.js'; import { getToken, resolveInstanceUrl } from '../lib/config.js'; import { startPoller, terminalDeliveryAckError } from '../lib/poller.js'; import { formatDigestFrame } from '../lib/digest-frame.js'; import { startWebhookServer, forwardToLocalWebhook, logOnlyHandler } from '../lib/webhook-server.js'; import { getAdapter, listAdapterNames } from '../lib/adapters/index.js'; import { getSession, getSessionEntry, setSession, clearSessions, wasEventHandled, recordHandledEvent, } from '../lib/session-store.js'; import { buildRecycleBriefing, idleMinutesSince, recycleThresholds, shouldStartFreshSession, } from '../lib/session-recycle.js'; import { catchUpBeforeTurn } from '../lib/pod-catch-up.js'; import { readLongTerm, syncBack } from '../lib/memory-bridge.js'; import { pollRetryPolicy } from '../lib/poll-retry.js'; import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js'; import { detectSkills, importSkills, parseSkillMeta } from '../lib/skills-import.js'; import { compileServerSkills } from '../lib/server-skills.js'; import { environmentForServer, normalizeSandboxTrust, parseEnvironmentFile, refuseWorkspaceTrust, resolveWorkspace, validateEnvironmentSpec, WORKSPACE_SANDBOX_ALLOW, } from '../lib/environment.js'; import { firstRunWorkspace, podIsConfirmedPrivate, workspaceTurnPlacement } from '../lib/workspace-trust.js'; import { ADAPTERS_WITH_DEFAULT_MCP, ADAPTERS_WITH_GRANT_BROKER, ADAPTERS_WITH_DEFAULT_SANDBOX, defaultMcpServers, seatBaseline, withDefaultMcpServer, withDefaultSandbox, } from '../lib/default-environment.js'; import { withholdGrantBroker } from '../lib/grant-broker-guard.js'; import { FOCUS_FRAME_MAX_CODE_POINTS, formatPodFocusFrame, readPodFocus, } from '../lib/pod-focus.js'; import { claimReleaseFor, ackResultFor, EMPTY_OUTPUT } from '../lib/claim-outcome.js'; import { prepareCommitAttribution } from '../lib/commit-attribution.js'; import { detectBwrap } from '../lib/sandbox/bwrap.js'; import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js'; import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js'; import { prepareMcpBeforeFirstPoll } from '../lib/mcp-first-run.js'; import { createSeatStop } from '../lib/seat-stop.js'; import { detectSeatbelt } from '../lib/sandbox/seatbelt.js'; import { describeUpstreamRefusal } from '../lib/upstream-refusal.js'; import { DEFAULT_HOOK_TIMEOUT_MS, clampHookTimeoutMs, forwardHookEvent, resolveHookToken, writeHooksConfig, } from '../lib/hooks-config.js'; import { formatRetryDelay, spawnRetryJitter, spawnRetryPolicy, } from '../lib/spawn-retry.js'; import { CONTENT_REFUSED_REASON, composeRefusalHandoff, contentRefusedResult, isContentRefusal, refusedItemRef, sanitizeHandoffAgent, } from '../lib/content-refusal.js'; import { ADDRESSED_EVENT_TYPES, CASCADE_DEFAULTS, CASCADE_ENV_VARS, CLAIMABLE_EVENT_TYPES, classifyTrigger, createCascadeGovernor, createClaimHandicap, createClaimKeeper, deliverChatReply, frameDecisionForkRule, peerHoldsFrame, resolveCascadeSettings, } from '../lib/enforcement.js'; const isPodFocusDiagnostic = (error) => ( typeof error?.code === 'string' && (error.code.startsWith('pod_focus') || error.code.startsWith('FOCUS_FRAME_')) ); // ── Token file I/O — ~/.commonly/tokens/.json (ADR-005) ─────────────── const tokensDir = () => join(homedir(), '.commonly', 'tokens'); const tokenFile = (name) => join(tokensDir(), `${name}.json`); export const saveAgentToken = (name, record) => { if (!existsSync(tokensDir())) mkdirSync(tokensDir(), { recursive: true }); // mode applies on create only — the record carries a live cm_agent_* secret, // same handling as performInit's .commonly-env. writeFileSync( tokenFile(name), JSON.stringify({ ...record, savedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', mode: 0o600 }, ); }; export const loadAgentToken = (name) => { const file = tokenFile(name); if (!existsSync(file)) return null; try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } }; export const deleteAgentToken = (name) => { const file = tokenFile(name); if (existsSync(file)) rmSync(file); }; // ── env-var bootstrap for `agent run` (#913) ──────────────────────────────── // The BYO connect page hands a fresh user two env exports and // `commonly agent run ` — on a machine that has never run // `commonly agent attach`, so no token file exists and run used to dead-end. // The runtime token IS the identity: everything else in the record either // comes from `GET /api/agents/runtime/installations` (agentName, instanceId, // podId) or is a local fact (which CLI binary to wrap). Returns a record ready // for saveAgentToken, or null when COMMONLY_AGENT_TOKEN isn't set (caller // falls back to the attach hint). // OpenCode is opt-in: unlike the established wrappers it must not be selected // just because its binary happens to be installed on the operator's machine. export const BOOTSTRAP_ADAPTER_DETECT_ORDER = ['claude', 'codex', 'pi']; export const NO_AGENT_CLI_CODE = 'no_agent_cli'; /** * A saved seat with no enforced sandbox: an adapter that takes one, no * workspace, and an environment that is absent or enforces nothing (a record * from before bootstrap applied the default, or one attached without an * environment file). Such a record is told how to pick the default up; it is * not rewritten here (Cofounder, 2026-10-16: none were found outside our own * tests). */ export const savedSeatLacksDefaultSandbox = (record) => Boolean(record) && ADAPTERS_WITH_DEFAULT_SANDBOX.has(record.adapter) && !record.workspacePath && (record.environment === null || record.environment === undefined || (typeof record.environment === 'object' && !Array.isArray(record.environment) && withDefaultSandbox(record.environment) !== record.environment)); /** Return the adapter binding resolved for this exact installation by the server. */ export const runtimeAdapterForInstallation = ({ installations, podId, instanceId } = {}) => { if (!Array.isArray(installations) || !podId) return null; const expectedPodId = String(podId); const expectedInstanceId = String(instanceId || 'default'); const installation = installations.find((row) => ( row?.type === 'installation' && String(row.podId || '') === expectedPodId && String(row.instanceId || 'default') === expectedInstanceId )); const adapter = String(installation?.runtimeAdapter || '').trim().toLowerCase(); return adapter || null; }; /** Non-broker adapters need a server-resolved binding on env-token seats. */ export const adapterRequiresServerBinding = (adapterName) => { const adapter = String(adapterName || '').trim().toLowerCase(); return Boolean(adapter) && adapter !== 'stub' && !ADAPTERS_WITH_GRANT_BROKER.has(adapter); }; const serverRuntimeAdapterProjectionState = (installations) => { if (!Array.isArray(installations)) return 'unknown'; const installationRows = installations.filter((row) => row?.type === 'installation'); if (installationRows.length === 0) return 'unknown'; return installationRows.some((row) => Object.prototype.hasOwnProperty.call(row, 'runtimeAdapter')) ? 'present' : 'legacy'; }; /** Return the refusal message unless this installation declares the same adapter. */ export const serverAdapterBindingRefusal = ({ adapterName, agentName, installations, podId, instanceId, includeAttachGuidance = true, } = {}) => { const adapter = String(adapterName || '').trim().toLowerCase(); if (!adapterRequiresServerBinding(adapter)) return null; const projectionState = serverRuntimeAdapterProjectionState(installations); if (projectionState === 'unknown') { return 'Could not verify the server adapter binding because the installations response is empty or missing. ' + 'Retry after the server returns this installation.'; } if (projectionState === 'legacy') { return `This Commonly server predates runtime adapter bindings; upgrade the server before running adapter '${adapter}'.`; } if (runtimeAdapterForInstallation({ installations, podId, instanceId }) === adapter) return null; const refusal = `Adapter '${adapter}' is not declared for this Commonly installation.`; if (!includeAttachGuidance) return refusal; return `${refusal} A pod owner must sign in with commonly login, then run commonly agent attach ${adapter}` + ` --pod ${podId || ''} --name ${agentName || ''}` + ` [--env ] before this seat can use adapter '${adapter}'.`; }; /** Verify a newly attached adapter before telling the operator how to run it. */ export const checkAttachedAdapterBinding = async ({ adapterName, agentName, podId, instanceId, instanceUrl, runtimeToken, clientFactory = createClient, } = {}) => { if (!adapterRequiresServerBinding(adapterName)) return { canRun: true }; let identity; try { identity = await clientFactory({ instance: instanceUrl, token: runtimeToken }) .get('/api/agents/runtime/installations'); } catch (error) { return { canRun: false, warning: `Could not verify the server adapter binding after attach: ${error.message}. ` + 'The installation was saved, but do not expect agent run to work until verification succeeds.', }; } const refusal = serverAdapterBindingRefusal({ adapterName, agentName, installations: identity?.installations, podId, instanceId, includeAttachGuidance: false, }); return refusal ? { canRun: false, warning: refusal } : { canRun: true }; }; export const printAttachRunGuidance = ({ agentName, binding, log = console.log, warn = console.warn, } = {}) => { if (binding?.canRun) { log(`\n Run with: commonly agent run ${agentName}`); } else { warn(`\n WARNING: ${binding?.warning || 'The server did not confirm this adapter binding.'}`); } }; // ── `agent run --adapter` against an existing token file (#2098) ──────────── // The flag picks the CLI once, at first-run bootstrap; after that the token // file's adapter is what runs. A different `--adapter` used to be dropped // without a word, so "switch this agent to Codex" kept running Claude and read // as Codex misbehaving. Refuse instead, and name the way out for each path: // a foreground run re-bootstraps from the Connect page's env exports after the // local record is removed; a daemon-managed seat is switched through the // registry, which the daemon applies on its next pass. // `run --allow write,web`: the grants a first-run workspace seat starts with // (TASK-294). An unknown name is refused rather than dropped. export const parseAllowGrants = (value) => { if (value === undefined || value === null) return []; const requested = String(value).split(',').map((grant) => grant.trim()).filter(Boolean); if (!requested.length || requested.some((grant) => !WORKSPACE_SANDBOX_ALLOW.includes(grant))) { throw new Error(`--allow takes ${WORKSPACE_SANDBOX_ALLOW.join(' and/or ')}, comma-separated ` + `(got ${JSON.stringify(String(value))})`); } return WORKSPACE_SANDBOX_ALLOW.filter((grant) => requested.includes(grant)); }; // Grants are fixed when the token file is written. A later --allow that // differs is refused with the way to change them, never applied quietly. export const checkRunAllowRequest = ({ record, requested = [] }) => { if (!requested.length) return { ok: true }; const savedList = record?.environment?.sandbox?.allow || []; const saved = WORKSPACE_SANDBOX_ALLOW.filter((grant) => savedList.includes(grant)); if (saved.join(',') === requested.join(',')) return { ok: true }; const name = record.agentName; return { ok: false, message: [ `'${name}' was set up with ${saved.length ? `--allow ${saved.join(',')}` : 'no --allow'}; ` + `--allow ${requested.join(',')} was not applied.`, `To change it: commonly agent detach ${name} --force, then re-run with the Connect-page ` + `env exports and --allow ${requested.join(',')}.`, ].join('\n'), }; }; // What a first-run workspace seat may do, said once at bootstrap. A write // grant hands it Bash, whose commands reach the network, so that line never // claims the seat is offline. const workspaceGrantLine = (workspacePath, allow) => { const write = allow.includes('write'); const web = allow.includes('web'); if (!write) { return web ? `this agent can read ${workspacePath}, run read-only git there, and fetch web pages; it cannot edit files.` : `this agent can read ${workspacePath} and run read-only git there; it cannot edit files or reach the web.`; } return `this agent can read and edit files in ${workspacePath} and run commands there` + (web ? ', and fetch web pages' : '') + '. Its commands can reach the network. Its .git stays read-only, so it cannot commit.'; }; export const checkRunAdapterRequest = ({ record, requestedAdapter, tokenPath }) => { const requested = String(requestedAdapter || '').trim().toLowerCase(); if (!requested) return { ok: true }; const bound = String(record?.adapter || '').trim().toLowerCase(); if (!bound || bound === requested) return { ok: true }; const name = record.agentName; return { ok: false, message: [ `'${name}' is bound to the ${bound} adapter by its token file (${tokenPath}); --adapter ${requested} was not applied.`, `To run it with ${requested} here: commonly agent detach ${name} --force, then re-run with the Connect-page env exports and --adapter ${requested}.`, `A daemon-managed seat switches with: commonly agent config ${name} --adapter ${requested}`, ].join('\n'), }; }; export const bootstrapAgentRecordFromEnv = async ({ name, env = process.env, clientFactory = createClient, adapterRegistry = { getAdapter, listAdapterNames }, adapterOverride = null, log = () => {}, startDir = process.cwd(), home, allow = [], platform = process.platform, }) => { const runtimeToken = (env.COMMONLY_AGENT_TOKEN || '').trim(); if (!runtimeToken) return null; if (!runtimeToken.startsWith('cm_agent_')) { throw new Error('COMMONLY_AGENT_TOKEN is set but is not a runtime token (expected cm_agent_… prefix).'); } const instanceUrl = (env.COMMONLY_API_URL || '').trim() || resolveInstanceUrl(undefined); let identity; try { identity = await clientFactory({ instance: instanceUrl, token: runtimeToken }) .get('/api/agents/runtime/installations'); } catch (err) { throw new Error( `Could not resolve the agent behind COMMONLY_AGENT_TOKEN against ${instanceUrl}: ${err.message}. ` + 'Check the token was copied whole and the URL matches the instance that issued it.', ); } const installs = Array.isArray(identity?.installations) ? identity.installations : []; const primary = installs.find((i) => i.type === 'installation' && i.status === 'active') || installs[0] || null; // The adapter names a binary on THIS machine — the one fact the server // cannot know. Explicit --adapter wins; otherwise probe the known CLIs. let adapterName = adapterOverride; if (adapterOverride) { const adapter = adapterRegistry.getAdapter(adapterOverride); if (!adapter) { throw new Error(`Unknown adapter '${adapterOverride}'. Known: ${adapterRegistry.listAdapterNames().join(', ')}`); } if (!await adapter.detect()) { throw new Error(`Adapter '${adapterOverride}' not found on PATH.`); } } else { const missing = []; for (const candidate of BOOTSTRAP_ADAPTER_DETECT_ORDER) { const adapter = adapterRegistry.getAdapter(candidate); // eslint-disable-next-line no-await-in-loop if (adapter && await adapter.detect()) { adapterName = candidate; break; } missing.push(candidate); } if (!adapterName) { const err = new Error( `No supported agent CLI found on PATH (looked for: ${BOOTSTRAP_ADAPTER_DETECT_ORDER.join(', ')}). ` + 'Install one, or pass --adapter .', ); err.code = NO_AGENT_CLI_CODE; throw err; } // The page's command names the person's own CLI; running a different one // without saying so is how a Claude Code user ended up with a Codex agent. if (missing.length > 0) { log(`${missing.join(', ')} not found on PATH, so this agent runs with ${adapterName}. ` + 'To choose, pass --adapter .'); } } if (adapterName === 'dsh') { throw new Error('dsh first-run bootstrap requires provider settings; attach it with --env before running'); } // The token's identity wins over the CLI argument — a mistyped name must // not fork a second identity for the same token. const agentName = identity?.agentName || name; if (String(agentName).toLowerCase() !== String(name).toLowerCase()) { log(`token belongs to '${agentName}', not '${name}' — using the token's identity`); } const bindingRefusal = serverAdapterBindingRefusal({ adapterName, agentName, installations: installs, podId: primary?.podId, instanceId: primary?.instanceId, }); if (bindingRefusal) throw new Error(bindingRefusal); // The daemon's baseline for a seat nobody wrote a spec for, sandbox // included: this record is derived from nothing the person declared. const environment = seatBaseline(buildDefaultEnvironment(adapterName), adapterName, { sandbox: true }); // A claude seat whose every pod is confirmed private reads the directory // it was started from (TASK-294); anything else keeps the public default. const workspace = firstRunWorkspace({ adapterName, installations: installs, startDir, home, platform, }); if (allow.length && !workspace?.workspacePath) { throw new Error(`--allow ${allow.join(',')} applies only to an agent that works in the folder it ` + 'starts in, and this one ' + (workspace?.reason ? `would read only its scratch folder, because ${workspace.reason}.${workspace.fix ? ` ${workspace.fix}` : ''}` : 'does not.')); } if (workspace?.workspacePath) { log(workspaceGrantLine(workspace.workspacePath, allow)); } else if (workspace?.reason) { log(`this agent can read only its own scratch folder, because ${workspace.reason}.` + (workspace.fix ? ` ${workspace.fix}` : '')); } return { agentName, displayName: identity?.displayName || primary?.displayName || agentName, instanceId: identity?.instanceId || primary?.instanceId || 'default', podId: primary?.podId || null, instanceUrl, runtimeToken, adapter: adapterName, environment: workspace?.workspacePath ? { ...environment, sandbox: { trust: 'workspace', ...(allow.length ? { allow } : {}) } } : environment, workspacePath: workspace?.workspacePath || null, }; }; /** * Enumerate every agent attached on this laptop — i.e. every file in * ~/.commonly/tokens/*.json. Cross-references the session store for a * "last turn" timestamp per agent so the user sees cold vs. live agents. * * Returns an array of plain records so the CLI table formatter stays simple * and tests can assert on shape directly. */ export const listLocalAgents = () => { const dir = tokensDir(); if (!existsSync(dir)) return []; return readdirSync(dir) .filter((f) => f.endsWith('.json')) .map((f) => f.slice(0, -'.json'.length)) .map((name) => { const record = loadAgentToken(name); if (!record) return null; let lastTurn = null; try { const sessionsFile = join(homedir(), '.commonly', 'sessions', `${name}.json`); if (existsSync(sessionsFile)) { const sessions = JSON.parse(readFileSync(sessionsFile, 'utf8')); // One entry per pod — we surface the most recent across pods. Object.values(sessions).forEach((s) => { if (s?.lastTurn && (!lastTurn || s.lastTurn > lastTurn)) lastTurn = s.lastTurn; }); } } catch { // Corrupt session store — not a hard error, just skip. } return { name, adapter: record.adapter || '?', podId: record.podId || '?', instanceUrl: record.instanceUrl || '?', instanceId: record.instanceId || 'default', savedAt: record.savedAt || null, lastTurn, }; }) .filter(Boolean); }; // Chat event types whose payload already contains the prompt the wrapper // should forward verbatim. Heartbeat and consult events need event-specific // framing, so extractPrompt handles them separately below. const PROMPT_EVENT_TYPES = new Set([ 'chat.mention', 'message.posted', 'dm.message', 'first_contact', ]); // A person waiting on an answer outranks the seat's own housekeeping. Measured // on the published 0.1.108 smoke (TASK-289, walk 5): the greeting turn for a // fresh connect ran 14 s, a mention landed 1 s into it, and the answer came // from the next turn 39.4 s after the mention. `first_contact` is the only // event that exists to introduce the seat rather than to answer anyone, so it // is the only one worth holding back. const DEFERRABLE_SOLO_EVENT_TYPES = new Set(['first_contact']); // Inside one batch the person's item leads the prompt; the greeting follows it. const PERSON_WAITING_EVENT_TYPES = new Set(['chat.mention', 'message.posted', 'dm.message']); const inboxPriority = (event) => { if (event?.deliveryMode === 'digest') return 2; return PERSON_WAITING_EVENT_TYPES.has(event?.type) ? 0 : 1; }; // Consultations have a per-request private response channel, rather than a // pod-chat delivery. They stay on their existing single-event path; every // event that represents the shared pod inbox is batched below. const PRIVATE_RESPONSE_EVENT_TYPES = new Set(['agent.ask', 'agent.ask.response']); // How often a live delivery lease is renewed: during a turn, and while a failed // turn's events wait for their retry. The server requeues a delivery whose // lease is 10+ minutes old (AGENT_EVENT_REQUEUE_DELIVERED_MINUTES). const DELIVERY_RENEW_INTERVAL_MS = 60_000; // A failed turn's events are carried in memory and re-run when the retry timer // fires (see `holdForRetry` in performRun). Each local re-run is a delivery // attempt the server's requeue cap (AGENT_EVENT_REQUEUE_MAX_ATTEMPTS, 3) never // counts, so a carry with no bound would retry poison content forever and // block every later event behind it. At this many consecutive failures of the // same event the carry lets it go: renewal stops, and the server's requeue // sweep redelivers it under the cap it already enforces. Three matches the // point where spawnRetryPolicy stops treating an unknown failure as transient. const HELD_RETRY_MAX_FAILURES = 3; const deliveryIdOf = (event) => { const deliveryId = event?.payload?.deliveryId; return typeof deliveryId === 'string' && deliveryId ? deliveryId : null; }; const countEvents = (count) => `${count} event${count === 1 ? '' : 's'}`; // ── default environment for adapters that benefit from auto-MCP wiring ───── // Which adapters can consume `mcp[]`, and the server declaration they get by // default, live in lib/default-environment.js — the daemon's per-seat token // provisioning reads the same module, so the two can never drift apart. const CODEX_PERMISSION_PROFILE_MIN_VERSION = [0, 138, 0]; const versionAtLeast = (version, minimum) => { const parts = String(version || '') .match(/\d+(?:\.\d+){1,2}/)?.[0] .split('.') .map(Number); if (!parts) return false; for (let i = 0; i < minimum.length; i += 1) { const current = parts[i] || 0; if (current > minimum[i]) return true; if (current < minimum[i]) return false; } return true; }; // The commonly behavior skill ships inside this package (cli/skills/commonly) // so a fresh attach can mount it into the agent without a network fetch. // Resolved from the module location — works both from source and when the // package is installed under node_modules/@commonlyai/cli. const BUNDLED_COMMONLY_SKILL_DIR = pathResolve( dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills', 'commonly', ); const reportedSkillName = (source) => { try { return parseSkillMeta(readFileSync(join(source, 'SKILL.md'), 'utf8'), basename(source)).name; } catch { return basename(source); } }; // Minimum-viable environment for fresh attach: wire @commonlyai/mcp so the // agent gets commonly_* kernel tools out of the box, AND mount the commonly // behavior skill so the agent knows the house rules (orient before posting, // reply conversationally, use the roster, don't double-post). Without the // skill, wrapper-spawned CLIs fly blind and behave inconsistently — some // narrate after tool-posting (double-post), some default to NO_REPLY on a // normal question. Returning null means "no default" — a non-consuming adapter // proceeds with environment=null exactly like before. export const buildDefaultEnvironment = (adapterName) => { if (!ADAPTERS_WITH_DEFAULT_MCP.has(adapterName)) return null; const environment = { mcp: defaultMcpServers(adapterName) }; // Only advertise the skill if it actually shipped (defensive: a broken // package that dropped the skills/ dir shouldn't hand mountSkills a // missing-source path every spawn). if (existsSync(BUNDLED_COMMONLY_SKILL_DIR)) { environment.skills = { claude: [BUNDLED_COMMONLY_SKILL_DIR] }; } return environment; }; // ── public-pod sandbox gate ───────────────────────────────────────────────── /** * Refuse to attach an agent to a publicly-readable pod unless it declares an * enforced sandbox. * * The public-agent sandbox is real and attack-tested, but it only engages once * an enforced sandbox is declared: `sandbox.trust: 'public'` (the adapters then * resolve the mode for the host at spawn) or an explicit non-'none' * `sandbox.mode`. Nothing previously connected "this pod is public" to "this * agent must be confined". An agent attached with no sandbox block simply ran * unconfined, silently, with the operator none the wiser. * * That is not hypothetical — `hq-support` ran that way in a 67-member public * pod until 2026-07-27. Its permission deny-list was doing the file-blocking * work while the OS-level sandbox never engaged at all. * * Deny-by-default only means something if ABSENT is refused, so this is a hard * error rather than a warning. If the pod's visibility cannot be determined * (older server, network failure, permissions), attach proceeds — failing the * attach on an unrelated fault would be its own footgun — but says so, because * a silent skip is how the original hole stayed invisible. * dsh is private-only, so it requires a successful visibility read and rejects * a public pod even without a sandbox declaration. */ export const assertSandboxDeclaredForPublicPod = async ({ client, podId, environment, adapterName, log = () => {}, }) => { if (!podId || !client) return; if (adapterName === 'dsh') { let pod; try { pod = await client.get(`/api/pods/${podId}`); } catch { throw new Error('dsh requires confirmed private pod visibility before attach'); } if (typeof pod?.publicRead !== 'boolean' || typeof pod?.communityListed !== 'boolean') { throw new Error('dsh requires confirmed private pod visibility before attach'); } if (pod?.publicRead || pod?.communityListed) { throw new Error('dsh supports private pods only; this pod is publicly readable'); } return; } // A `workspace` seat lets the room read its start directory (TASK-294), so it // needs the pod CONFIRMED private, not merely not known to be public: a pod // whose visibility cannot be read fails closed here, the way dsh does above. if (environment?.sandbox?.trust === 'workspace') { let pod; try { pod = await client.get(`/api/pods/${podId}`); } catch { throw new Error( 'sandbox.trust=workspace requires confirmed private pod visibility before attach, ' + 'and this pod\'s visibility could not be read', ); } if (podIsConfirmedPrivate(pod)) return; const label = pod?.name ? `"${pod.name}"` : podId; const reported = typeof pod?.publicRead === 'boolean' && typeof pod?.communityListed === 'boolean'; let why = 'did not report its visibility'; if (reported) why = pod.publicRead || pod.communityListed ? 'is publicly readable' : 'is open to join'; throw new Error( `${label} ${why}, so sandbox.trust=workspace would let people you do not control read this ` + 'agent\'s start directory. Declare "sandbox": { "trust": "public" } for this pod, or attach ' + 'to a private, invite-only pod.', ); } // Normalized FIRST (Vera 71715, Wren 71718): the predicate below reads a // legacy `internal` record as the `public` it means, the same mapping the // attach gate and both adapters use. Reading it raw refused a record that // means public one call before the gate #1840 fixes — TASK-113 claims // attach's inconsistent derivation is closed, and this path is attach's. const sandbox = normalizeSandboxTrust(environment?.sandbox); const mode = sandbox?.mode; const trust = sandbox?.trust; // An ENFORCED declaration is one the adapters act on: a public trust (whose // mode they resolve per host at spawn) or an explicit non-'none' mode. This // mirrors the daemon's predicate in lib/default-environment.js, so the shape // the daemon writes for an unconfigured seat is one attach also accepts. const declared = mode !== 'none' && (trust === 'public' || typeof mode === 'string'); if (declared) return; let pod = null; try { pod = await client.get(`/api/pods/${podId}`); } catch { log( 'warning: could not read pod visibility, so the public-pod sandbox check ' + 'was skipped. If this pod is public, attach with sandbox.trust=public.', ); return; } const isPublic = Boolean(pod?.publicRead) || Boolean(pod?.communityListed); if (!isPublic) return; const label = pod?.name ? `"${pod.name}"` : podId; throw new Error( `${label} is publicly readable, so this agent would take instructions from ` + 'people you do not control — but its environment declares no sandbox, and ' + 'an undeclared sandbox means NO sandbox.\n\n' + 'Add a sandbox block to the environment file and retry:\n\n' + ' "sandbox": { "trust": "public" }\n\n' + 'That is the whole declaration: the adapters pick the enforced mode for ' + 'the host (Seatbelt on macOS, bwrap on Linux). Pin one explicitly with ' + '"mode": "read-only" or "workspace" if you want a specific access level. ' + 'To attach an agent to a private pod instead, pass that pod id.', ); }; // ── wake: toggle ADR-018 ambient wake on an attached agent ────────────────── /** * Set `config.wakeOnMessage.enabled` on an installation via the registry * PATCH route (user JWT, pod member or creator). Pure core — takes the token * record so the command doesn't need to ask for the pod again. */ export const setWakeOnMessage = async ({ client, record, enabled }) => { if (!record?.podId || !record?.agentName) { throw new Error('token record is missing podId/agentName — re-attach the agent'); } const instanceId = record.instanceId || 'default'; await client.patch( `/api/registry/pods/${record.podId}/agents/${record.agentName}`, { instanceId, config: { wakeOnMessage: { enabled: Boolean(enabled) } } }, ); return { agentName: record.agentName, podId: record.podId, instanceId, enabled: Boolean(enabled) }; }; /** * Update the server-side configuration for an existing local agent. The * registry PATCH route already fans this change out to accessible pod peers; * keeping the CLI on that route avoids a second config API and lets the daemon * pick up the change on its next work-list pass. */ export const updateAgentConfiguration = async ({ client, record, adapter = null, model = null, effort = null, envPath = null, runtimeKey = false, parseEnv = parseEnvironmentFile, adapterRegistry = { getAdapter, listAdapterNames }, }) => { if (!record?.agentName) { throw new Error('token record is missing agentName — re-attach the agent'); } if (runtimeKey) { if (adapter || envPath) throw new Error('--adapter and --env require an owner login; run commonly login'); const controls = Object.fromEntries(Object.entries({ model, effort }) .filter(([, value]) => value !== null && value !== undefined)); if (!Object.keys(controls).length) throw new Error('provide --model or --effort'); const environment = { ...record.environment, ...controls }; const validation = validateEnvironmentSpec(environment); if (!validation.ok) throw new Error(validation.errors.join('; ')); const result = await client.patch('/api/agents/runtime/configuration', { agentName: record.agentName, instanceId: record.instanceId || 'default', ...controls, }); return { agentName: record.agentName, instanceId: record.instanceId || 'default', podId: result.podId, changed: Object.keys(controls), environment, }; } const instanceId = record.instanceId || 'default'; const runtime = {}; const environmentRuntime = {}; if (adapter !== null && adapter !== undefined) { const normalizedAdapter = String(adapter).trim().toLowerCase(); const knownAdapters = adapterRegistry.listAdapterNames(); const selectedAdapter = adapterRegistry.getAdapter(normalizedAdapter); if (!selectedAdapter || !knownAdapters.includes(normalizedAdapter)) { throw new Error( `Unknown adapter '${normalizedAdapter}'. Known: ${knownAdapters.join(', ')}`, ); } if (!await selectedAdapter.detect()) { throw new Error(`Adapter '${normalizedAdapter}' not found on PATH. Install it and retry.`); } runtime.adapter = normalizedAdapter; } if (model !== null && model !== undefined) { const normalizedModel = String(model); const validation = validateEnvironmentSpec({ model: normalizedModel }); if (!validation.ok) throw new Error(validation.errors.join('; ')); runtime.model = normalizedModel; environmentRuntime.model = normalizedModel; } if (effort !== null && effort !== undefined) { const normalizedEffort = String(effort); const validation = validateEnvironmentSpec({ effort: normalizedEffort }); if (!validation.ok) throw new Error(validation.errors.join('; ')); runtime.effort = normalizedEffort; environmentRuntime.effort = normalizedEffort; } const config = {}; if (Object.keys(runtime).length) config.runtime = runtime; let environment = null; if (envPath) { environment = await parseEnv(envPath); } else if (record.environment && typeof record.environment === 'object' && !Array.isArray(record.environment)) { environment = { ...record.environment }; } // Keep the two historical control surfaces coherent. If a local token has // an ADR-008 environment, a model/effort flag must update that declaration // too; otherwise the daemon would correctly prefer the old explicit value // over the new legacy runtime overlay. if (environment && Object.keys(environmentRuntime).length) { environment = { ...environment, ...environmentRuntime }; } if (!environment && Object.keys(environmentRuntime).length) { environment = { ...environmentRuntime }; } if (environment?.provider !== undefined && !adapterRegistry.getAdapter(String(runtime.adapter || record.adapter || '').trim().toLowerCase())?.supportsProvider) { throw new Error('environment.provider requires a provider-capable adapter'); } if (environment) config.environment = environmentForServer(environment); if (!Object.keys(config).length) { throw new Error('provide at least one of --adapter, --model, --effort, or --env'); } // The token's pod may be an attention room rather than an installation. // Reuse Settings' owner-filtered projection instead of guessing from it. const settings = await client.get( `/api/registry/agents/${encodeURIComponent(record.agentName)}/manage-settings?instanceId=${encodeURIComponent(instanceId)}`, ); const podId = settings?.runtimeManaged ? settings.runtimeSourcePodId : settings?.writablePodId; if (typeof podId !== 'string' || !podId.trim()) { throw new Error('No writable runtime installation found for this agent; check its Settings and computer binding.'); } await client.patch( `/api/registry/pods/${encodeURIComponent(podId)}/agents/${encodeURIComponent(record.agentName)}`, { instanceId, config }, ); return { agentName: record.agentName, podId, instanceId, changed: Object.keys(config), ...(runtime.adapter ? { adapter: runtime.adapter } : {}), ...(environment ? { environment } : {}), }; }; // ── attach: register a local-CLI-wrapped agent (ADR-005) ──────────────────── /** * The sandbox an attach will actually run under — derived, then gated. * * The trust is normalized ONCE, before it is used for anything. A stored * `internal` means `public` (environment.js, Wren 69585), and resolving the mode * off the raw value made this gate disagree with the adapters in BOTH * directions: `{ trust: 'internal' }` with no declared mode left the attach-time * mode at `'none'` and refused nothing here, and `internal` beside * `mode: 'workspace'` was refused outright as "implemented only for public codex * or Claude adapters" — turning away a record both adapters confine happily. * * This does NOT take the confinement decision from the adapters. They normalize * and re-derive from the environment themselves (claude.js:615, codex.js:546) and * the attach-time value never reaches them, so a legacy record was confined * either way; what was wrong is that this gate's verdict contradicted theirs, and * its refusal for the `mode: 'none'` shape landed later, at adapter spawn, after * attach had already published and minted. Normalizing here makes the two agree * and puts the refusal where the user can see it (Kai, TASK-113; re-measured at * source after Vera 71664). * * Whether the resolved mode is AVAILABLE here — bwrap installed, macOS for * Seatbelt, the codex permission-profile version — is deliberately not this * function's question: that needs the host and the detected version and stays at * the call site. What an adapter can honour at all is just a mode and a name, so * that check is in here, with witnesses of both signs. * * Why this gate was the one that disagreed: the resolver compared the RAW trust, * so a caller that did not normalize first read a legacy `internal` record as * non-public. The adapters normalize at their own entry (claude.js:615, * codex.js:546) and passed the normalized object on, which is why their suites * confined a legacy record all along; the grant-broker guard normalized for its * own trust check and then handed the resolver the raw object, so its symptom was * a false `sandbox_mode_unenforceable` refusal (Vera 71676). Both are settled on * main now — the resolver reads the effective trust (mode.js:38) and the guard * imports the same reader from environment.js (grant-broker-guard.js:186) — so * this helper needs no coupling to them: it normalizes once, here, before its own * two gates, because those gates compare `trust` directly. */ export const resolveAttachSandbox = ({ environment, adapterName, adapter = null, platform = process.platform, } = {}) => { // An adapter that enforces one confined shape of its own (dsh: Seatbelt with a // restricted network and NO trust) says so here, and its check is the one that // decides: the trust-keyed gates below cannot express that shape, so before // this they refused the only environment dsh accepts (D106). The adapter's // check throws on every other shape, so this admits nothing it would refuse. if (typeof adapter?.confinesOwnSandbox === 'function' && adapter.confinesOwnSandbox(environment)) { return { mode: environment.sandbox.mode, trust: environment.sandbox.trust }; } const sandbox = normalizeSandboxTrust(environment?.sandbox); const trust = sandbox?.trust; // `workspace` is claude-only for now (TASK-294). Refused here with the words // the adapter would use at spawn, so the attach fails before anything is // published rather than at the seat's first turn. if (trust === 'workspace' && adapterName !== 'claude') { refuseWorkspaceTrust(sandbox, adapterName || 'this'); } // Both confined trusts take their mode from the host the same way; they // differ in what the policy inside the sandbox allows, not in the sandbox. const confined = trust === 'public' || trust === 'workspace'; const mode = sandbox?.mode || (confined ? resolvePublicSandboxMode(sandbox, platform) : 'none'); if (confined && mode === 'none') { throw new Error( `sandbox.trust=${trust} requires an enforced sandbox mode; refusing to attach unsandboxed`, ); } // Moved in from the call site (Vera 71675): this is the check the raw compare // used to fail closed on, refusing `internal` beside `mode: 'workspace'`. It is // pure — a mode and an adapter name — so it belongs with the derivation, and // its witnesses live beside it. if ((mode === 'workspace' || mode === 'read-only') && (!confined || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName))) { throw new Error( `sandbox.mode=${mode} requires public trust and an adapter with sandbox enforcement`, ); } return { mode, trust }; }; /** * Publish, install, and mint a runtime token for a local-CLI-wrapped agent. * Pure core — the commander action wraps this with config loading + logging. */ export const performAttach = async ({ client, adapterName, agentName, podId, displayName, envPath = null, wakeOnMessage = false, memoryFrom = null, memoryInstance = 'default', log = () => {}, adapterRegistry = { getAdapter, listAdapterNames }, seatbelt: probeSeatbelt = detectSeatbelt, }) => { const adapter = adapterRegistry.getAdapter(adapterName); if (!adapter) { throw new Error( `Unknown adapter "${adapterName}". Known: ${adapterRegistry.listAdapterNames().join(', ')}`, ); } const detected = await adapter.detect(); if (!detected) { throw new Error(`${adapterName} not found on PATH. Install it and retry.`); } if (adapterName === 'dsh' && !envPath) { throw new Error('dsh requires an environment file with provider and model'); } // ADR-008 §invariant #4: sandbox failure is a hard stop. Validate the env // here so attach refuses up front rather than handing the user a runtime // that silently degrades to unsandboxed. let environment = null; let workspace = null; if (envPath) { // An explicit spec augments the seat; it does not remove the Commonly // kernel server. The daemon applies this same baseline before every spawn. environment = withDefaultMcpServer(await parseEnvironmentFile(envPath), adapterName); if (environment?.provider !== undefined && !adapter.supportsProvider) { throw new Error('environment.provider requires a provider-capable adapter'); } workspace = await resolveWorkspace(environment, agentName, dirname(envPath)); if (typeof adapter.validateEnvironment === 'function') { await adapter.validateEnvironment(environment, workspace.path); } log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`); const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName, adapter }); if (sandboxMode === 'bwrap') { const bwrap = detectBwrap(); if (!bwrap.available) { throw new Error(`sandbox.mode=bwrap requested but unavailable: ${bwrap.error}`); } if (environment.sandbox?.network?.policy === 'restricted') { log( 'WARNING: bwrap mode does not enforce host allowlist; declared hosts ' + 'are advisory in Phase 1. Use sandbox.mode=container for enforced policy.', ); } } else if (sandboxMode === 'workspace' || sandboxMode === 'read-only') { if (adapterName === 'codex') { if (!versionAtLeast(detected.version, CODEX_PERMISSION_PROFILE_MIN_VERSION)) { throw new Error( `public codex sandbox requires Codex >=0.138.0 permission profiles; ` + `detected ${detected.version || 'unknown'}`, ); } log( `sandbox: public ${sandboxMode} via Codex native permission profile ` + '(deny-by-default filesystem + network)', ); } else { const adapterLabel = ({ opencode: 'OpenCode', qwen: 'Qwen', claude: 'Claude' })[adapterName] || adapterName; const seatbelt = probeSeatbelt(); if (!seatbelt.available) { // dsh refuses bwrap, so it gets no Linux hint that leads to a second refusal. throw new Error(adapterName === 'dsh' ? `confined dsh sandbox.mode=${sandboxMode} requires macOS Seatbelt: ${seatbelt.error}.` : `public ${adapterLabel} sandbox.mode=${sandboxMode} requires macOS Seatbelt: ` + `${seatbelt.error}. On Linux use sandbox.mode=bwrap.`); } log(adapterName === 'dsh' ? `sandbox: confined dsh ${sandboxMode} via macOS Seatbelt (restricted network)` : `sandbox: public ${sandboxMode} via macOS Seatbelt ` + `(deny-by-default host filesystem; ${adapterLabel}/MCP network retained)`); } } else if (sandboxMode !== 'none' && sandboxMode !== undefined) { throw new Error( `sandbox.mode=${sandboxMode} is not yet implemented in the local-CLI driver. ` + `Supported locally: none, bwrap, and public Codex/Claude/OpenCode/Qwen workspace/read-only.`, ); } } else { // No --env provided. Apply a sensible default if the adapter supports MCP // so a fresh `commonly agent attach claude` gives the agent commonly_* // kernel tools out of the box (parity with cloud-codex, which boots with // @commonlyai/mcp pre-wired in ~/.codex/config.toml). An explicit --env // keeps its declarations and receives the same kernel MCP baseline above. // See #440 for the gap that motivated this default. environment = buildDefaultEnvironment(adapterName); if (environment) { log('environment: applied default with @commonlyai/mcp wired'); } } // Deny-by-default has to mean "absent = refuse". Runs outside the branch // above because the dangerous case is an agent attached with NO sandbox // declaration at all — the branch that validates sandbox settings never // executes for those, so the agent silently ran unsandboxed. await assertSandboxDeclaredForPublicPod({ client, podId, environment, adapterName, log, }); // Identity-bearing runtime tag from the adapter (e.g. 'codex', 'claude- // code'). Falls back to adapter.name for adapters that haven't been // updated to the two-field scheme. Paired with `host: 'byo'` below so a // CLI-attached agent and a cloud-hosted agent of the same identity share // the runtimeType and differ only on host. Replaces the old // `runtimeType: 'local-cli'` + `wrappedCli` pair (kept readable on the // server via legacy normalization). const runtimeType = adapter.runtimeType || adapter.name; if (memoryFrom) { const capability = await client.get('/api/registry/install/memory-handoff'); if (capability?.installTimeMemoryHandoff !== true) { throw new Error('Server does not support install-time memory handoff; upgrade the server before attaching'); } } // Idempotent publish — already-published is expected and fine. Install will // surface the real error if the manifest is truly unusable. try { await client.post('/api/registry/publish', { manifest: { name: agentName, version: '1.0.0', description: `A local ${adapter.name} agent.`, runtimeType, }, displayName: displayName || agentName, }); } catch (err) { log(`publish skipped: ${err.message}`); } // config.runtime is an opaque blob the backend stores verbatim — kernel // does not interpret runtimeType here. ADR-004 §Identity's `sourceRuntime` // is a different field (memory-sync tag); don't conflate the two. // ADR-008 §Attach + run with an environment: include the resolved env spec // on config.environment so cross-driver implementations can realize it // server-side without re-reading the user's file. const installResult = await client.post('/api/registry/install', { ...(memoryFrom ? { resumeExisting: true, memoryFrom: { agentName: memoryFrom, instanceId: memoryInstance }, } : {}), agentName, podId, displayName: displayName || agentName, version: '1.0.0', config: { runtime: { runtimeType, adapter: adapterName, host: 'byo', }, ...(environment ? { environment: environmentForServer(environment) } : {}), // ADR-018: ambient wake is a per-install opt-in read by // agentMentionService (`config.wakeOnMessage.enabled === true`, default // OFF). Until now the only way to set it for a BYO seat was a DB write. ...(wakeOnMessage ? { wakeOnMessage: { enabled: true } } : {}), }, scopes: ['context:read', 'messages:write', 'memory:read', 'memory:write'], }); const installation = installResult.installation || installResult; const instanceId = installation.instanceId || 'default'; if (memoryFrom) { const memoryCopy = installResult.memoryCopy; if (!memoryCopy) throw new Error('Server does not support install-time memory handoff; upgrade the server before attaching'); log(memoryCopy.reason === 'source-empty' ? 'hosted agent has no long-term memory to copy' : 'hosted long-term memory is present in the local agent'); } let runtimeToken = installResult.runtimeToken; if (!runtimeToken) { // ADR-005 §detach + reattach: the agent User row's hashed token persists // across detach (per ADR-001 identity-continuity), so re-issuance from a // fresh attach hits the "already has a runtime token" branch and gets a // {existing: true} response with no usable raw token. Pass force:true so // the server clears + re-mints — we just deleted the local copy on // detach, the prior hash is unrecoverable from the CLI side anyway. const tokenData = await client.post( `/api/registry/pods/${podId}/agents/${agentName}/runtime-tokens`, { force: true }, ); runtimeToken = tokenData.token; } if (!runtimeToken) { throw new Error('Runtime token was not returned by install or tokens endpoint'); } return { installation, instanceId, runtimeToken, detected, wrappedCli: adapter.name, displayName: installation.displayName || displayName || agentName, environment, workspace, }; }; // ── memory import (retention plan Phase C) ────────────────────────────────── /** * Detect → preview → confirm → promote local memory into the agent's * envelope. Interactive by design: local memory can hold private material, * so nothing is sent without an explicit yes (or --yes for scripts; a * non-TTY without --yes aborts rather than guessing). * * Exported for tests; the attach flag and the import-memory subcommand are * both thin wrappers over this. */ export const runMemoryImport = async ({ client, explicitPath = null, yes = false, cwd = process.cwd(), log = console.log, promptFn = null, }) => { const sources = detectMemorySources({ cwd, explicitPath }); if (!sources.length) { log('No local memory found (looked for CLAUDE.md / MEMORY.md / ~/.claude project memory). Pass a path to import a specific file.'); return { imported: false, reason: 'nothing-found' }; } const totalKb = Math.max(1, Math.round(sources.reduce((n, s) => n + s.bytes, 0) / 1024)); log(`Found ${sources.length} memory source${sources.length === 1 ? '' : 's'} (${totalKb}KB):`); for (const s of sources) { log(` ${s.path} [${s.label}, ${Math.max(1, Math.round(s.bytes / 1024))}KB]`); } // Compose up-front so size-ceiling errors surface before the prompt, and // the preview shows exactly what would land in the envelope. const composed = composeImport(sources); const previewLines = composed.split('\n').slice(0, 8); log('\nPreview:'); for (const line of previewLines) log(` | ${line}`); log(` | … (${composed.length} chars total)\n`); if (!yes) { if (!process.stdin.isTTY && !promptFn) { log('Not a terminal — re-run with --yes to confirm the import.'); return { imported: false, reason: 'needs-confirmation' }; } const ask = promptFn || (async (q) => { const { createInterface } = await import('readline/promises'); const rl = createInterface({ input: process.stdin, output: process.stdout }); const answer = await rl.question(q); rl.close(); return answer; }); const answer = await ask('Import into this agent\'s memory? Appends to long_term; never overwrites. [y/N] '); if (!/^y(es)?$/i.test(String(answer).trim())) { log('Import cancelled.'); return { imported: false, reason: 'declined' }; } } const result = await importMemory(client, { sources }); log(`✓ Imported ${result.files} file${result.files === 1 ? '' : 's'} into long_term memory${result.appended ? ' (appended to existing)' : ''}.`); return { imported: true, ...result }; }; // ── run: local-CLI wrapper loop (ADR-005) ──────────────────────────────────── // TASK-293, and the rule AX entry 43 earned: a frame that says "otherwise // return NO_REPLY" must also say what the otherwise costs. Silence here is an // exact match (`silentReply` in runTurn), so a seat that checkpointed and then // ended its turn on a summary of doing nothing had the summary posted — four // times in 24 h, from two seats. No wrapper-side classifier guesses which // summaries were meant for nobody; the consequence goes where the instruction // is. Every prompt whose final text the wrapper posts to the pod carries it. // agent.ask does not: its final text goes only to the asking agent. const FINAL_TEXT_IS_POSTED = 'Your final text is posted to the pod unless it is exactly NO_REPLY. ' + 'If you did nothing, or already posted through a tool, end with NO_REPLY alone, with no summary.'; const extractPrompt = (event) => { const p = event.payload || {}; if (PROMPT_EVENT_TYPES.has(event.type)) { const content = p.content || p.prompt || p.text || null; if (content) return content; // #896: a message-shaped event with a messageId but NO content used to be // acked silently as "no prompt — no-op", which reads exactly like a // swallowed wake (the pilot's boot-backlog mystery — repro'd 2026-08-12: // cold starts were healthy; content-less payloads were the whole story). // The wrapper has enough to recover: name the message and let the agent // read the room. Between silence and one model turn, the #887 rule says // spend the turn — the claim gate and cascade cap bound the cost. if (p.messageId) { return [ `[Recovered wake: this ${event.type} event named message ${p.messageId} but carried no content — ` + 'a producer bug, not your fault. Read the recent messages before deciding anything.]', 'Check the pod\'s recent messages (commonly_get_context / commonly_get_messages), find that message,', 'and decide whether it needs YOU specifically. Most likely it does not: return NO_REPLY.', ].join('\n'); } return null; } if (event.type === 'heartbeat') { return p.content || [ 'Heartbeat tick.', 'Read your HEARTBEAT.md workspace file and follow it exactly.', 'HEARTBEAT_OK is a return value — never post it or any narration to pod chat.', ].join('\n'); } if (event.type === 'agent.ask') { if (!p.requestId || !p.question) return null; const sender = p.fromAgent ? `@${p.fromAgent}${p.fromInstanceId && p.fromInstanceId !== 'default' ? `:${p.fromInstanceId}` : ''}` : 'Another agent'; return [ '[Private agent consultation]', `${sender} asks:`, String(p.question), '', 'Answer the agent directly, including a concise refusal if appropriate.', 'Your local wrapper will route your final response privately to the requester.', 'Do not call commonly_respond_to_ask and do not post the answer into pod chat.', ].join('\n'); } if (event.type === 'agent.ask.response') { if (!p.response) return null; const responder = p.fromAgent ? `@${p.fromAgent}${p.fromInstanceId && p.fromInstanceId !== 'default' ? `:${p.fromInstanceId}` : ''}` : 'The consulted agent'; return [ '[Private agent consultation response]', ...(p.question ? [`Your question: ${String(p.question)}`] : []), `${responder} answered:`, String(p.response), '', 'Use this answer to continue the work you were doing.', 'Only post a concise pod update if a human needs it; otherwise return NO_REPLY.', FINAL_TEXT_IS_POSTED, ].join('\n'); } if (event.type === 'decision.ruled') { if (!p.decisionId || !p.pick || !p.ruledAt) return null; const ruler = p.ruledBy?.username || 'A human'; return [ '[Decision ruled]', `${ruler} chose: ${String(p.pick)}`, `Decision: ${String(p.decisionId)}`, `Ruled at: ${String(p.ruledAt)}`, '', 'Continue with this ruling. Post a concise update only if it materially helps the pod; otherwise return NO_REPLY.', ].join('\n'); } if (event.type === 'approval.resolved') { return p.content || null; } return null; }; /** * Start the run loop. Polls /events, spawns the adapter per event, posts the * result into the pod, acks. * * ADR-005 invariant #4: serialized per agent. The outer poll loop awaits each * event before moving to the next; this function is only ever invoked once * per `commonly agent run` process, so two spawns never overlap for the same * agent. * * ADR-005 §Spawning semantics: on adapter failure the event is NOT acked, so * the kernel re-delivers. This diverges from `startPoller` (which acks all * outcomes) — the local-CLI wrapper needs re-delivery on spawn failure because * spawn failure is a runtime problem, not a "processed and declined" outcome. * * ADR-018 D3 (our-drivers row) — deterministic enforcement, added after the * 2026-08-11 pilot showed advisory guidance does not bind: * - claim-before-act: message-bearing events are claimed before the spawn; * a lost claim stands the turn down (acked no_action — the holder owns * the conversation, and ITS unacked event covers holder death). * - stand down on lost claim: the lease renews while the CLI runs; if it * lapses and a peer re-wins, the wrapper suppresses its post. * - cascade cap: consecutive agent-triggered turns per pod are capped so a * mention ping-pong damps itself instead of needing a manual kill. * - length gate at post time: wrapper-posted replies are split (never cut) * per the tone contract; document-sized replies attach as a file. * Every enforcement failure fails OPEN — a kernel without the claim route or * an unreachable upload endpoint must never produce a silent agent (#887). */ export const performRun = ({ instanceUrl, token, adapter, agentName, displayName = null, instanceId = 'default', podId = null, environment = null, workspacePath = null, intervalMs = 5000, log = () => {}, onError, setTimeoutImpl = setTimeout, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, setDeadlineTimeoutImpl = setTimeout, clearDeadlineTimeoutImpl = clearTimeout, turnTimeoutMs, retryJitterRatio, claimLeaseSeconds = 90, // Undefined means "not overridden" — resolveCascadeSettings falls back to // the env var, then to the shipped default. Passing a value here still // wins, which is how the tests pin a cap of 1. cascadeCap, cascadeAddressedGrace, cascadeResetMs, chatCharLimit = 400, maxChatChunks = 3, claimYieldDelayMs = 3000, // ADR-024 D3: a poll is an inbox batch, not ten independent interrupts. // Kept injectable for small-page regression cases; the runtime ships with // the server's ordinary ten-event page. inboxBatchLimit = 10, // TASK-102 part B. Undefined means "use the server's published default"; a // number here is the operator's ask, and the policy read at boot is what // makes a clamp visible instead of silent. spawnCredentialTtlSeconds = null, spawnCredentialLeaseFactory = createSpawnCredentialLease, revokeOrphansImpl = revokeOrphanedSpawnCredentials, sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }), // The agent to @mention when the model provider refuses a request's content // (lib/content-refusal.js). Null: the refusal notice names no one. reportRuntime = false, resolveOwnProviderKey = null, runtimeReportClient = null, refusalHandoff = null, }) => { reportRuntime = reportRuntime && WALKED_HARNESSES.includes(adapter?.name); if (environment?.provider !== undefined && !adapter?.supportsProvider) { throw new Error('environment.provider requires a provider-capable adapter'); } const requestedTurnTimeout = Number(turnTimeoutMs ?? process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS); const effectiveTurnTimeoutMs = Number.isFinite(requestedTurnTimeout) && requestedTurnTimeout > 0 ? Math.min(requestedTurnTimeout, 2_147_483_647) : 15 * 60 * 1000; const client = createClient({ instance: instanceUrl, token }); const runtimeReporter = createRuntimeReporter({ client: runtimeReportClient || { post: async () => {} }, harness: adapter.name, detect: () => adapter.detect(), }); const runtimeConnected = reportRuntime ? runtimeReporter.connect() : Promise.resolve(); const missingModelNotices = new Set(); const handoffAgent = sanitizeHandoffAgent(refusalHandoff); if (refusalHandoff && !handoffAgent) { log(`--refusal-handoff ignored: '${String(refusalHandoff).slice(0, 64)}' is not an agent handle`); } // THE BOOT SWEEP IS THE SECOND NET, AND IT HAPPENS ONCE PER PROCESS. A seat // that dies mid-turn (kill, crash, reboot) leaves its child credential live // until its own TTL expires, because `close()` never ran; this is what collects // those. Fire-and-forget by contract: `performRun` is synchronous for its // callers, the sweep is best-effort, and a sweep that failed must not stop a // seat from spawning — it logs and returns. Per process rather than per spawn // because the orphans being collected belong to the PREVIOUS process. // // The promise wrapper is not ceremony: it makes a throwing OR rejecting // implementation of the seam a log line instead of an unhandled rejection, // which is the difference between a diagnostic and a crash in a seat that is // otherwise fine. void Promise.resolve() .then(() => revokeOrphansImpl({ client, log })) .catch((err) => log(`spawn credential boot sweep failed (${err?.message ?? err})`)); // The bounds the server will clamp to. READ ONLY IF THERE IS AN ASK TO CLAMP: // with no `COMMONLY_SPAWN_TTL_SECONDS` the cli sends no `ttlSeconds` at all // and the server's default is what it is, so a per-run read would be a request // on every seat's boot in exchange for nothing. Read once and cached, because // the bounds are constants for the life of a process. `readSpawnPolicy` never // rejects (a failure resolves to null), so this cannot fail a spawn; it just // sends the ask unclamped and lets the server decide. let spawnPolicyPromise = null; const spawnPolicyForAsk = () => { if (spawnCredentialTtlSeconds === null || spawnCredentialTtlSeconds === undefined) return null; if (spawnPolicyPromise === null) spawnPolicyPromise = readSpawnPolicy({ client, log }); return spawnPolicyPromise; }; // THE SEAT'S OWN RECORD IS THE ONLY DECLARATION THERE IS. The broker reaches a // seat as an injected MCP entry written by hand into its environment — no // server projection puts it there (`GRANT_BROKER_URL` is imported by one // route, the daemon assignment, and that one already refuses). So a seat run // by `commonly agent run` under launchd spawns the entry verbatim, and the // confinement it promises is declared in a different field (`sandbox`), which // can disagree. The daemon half of this refusal already runs // (`daemon-supervisor.js`); this is the same predicate at the only other site // that can see the record — same code, so one refusal with two emitters. // // IN MEMORY, RECORD UNTOUCHED (wren 71641): the withheld environment is what // the adapter spawns with, and the stored record keeps its broker, so the // daemon stays the layer that judges a host it knows. Applied once for the // run rather than per turn: `agent run` reads its record once at boot. const requestedSeatEnvironment = withholdGrantBroker(environment, adapter?.name, { instanceUrl, onRefuse: (refusal, names) => log( `${refusal.code} (${refusal.reason}) — withholding ${names.join(', ')}: ${refusal.detail}`, ), }); // Without native effort inventory a machine runner cannot promise that a // stored effort is supported. Preserve the record and use harness default. // Standalone CLI behavior remains under the operator's control. const runtimeInventoryPromise = reportRuntime ? Promise.resolve(adapter.inventory?.()).then((value) => value || { models: null, efforts: [] }) .catch(() => ({ models: null, efforts: [] })) : Promise.resolve(null); let seatEnvironment = requestedSeatEnvironment; // OpenCode retains tool-call IDs in its resumed history. A Gemini proxy // marks those IDs with signature references, so changing providers must // start a fresh session before another upstream can receive them. const sessionBinding = adapter?.name === 'opencode' ? createHash('sha256').update(JSON.stringify([ seatEnvironment?.provider?.id || null, seatEnvironment?.provider?.baseURL || null, seatEnvironment?.provider?.keyFile || null, ])).digest('hex') : null; let running = true; // Stop-after-N-auth-failures: without this, a revoked token leaves the // poller hammering 401s forever at 60s backoff — invisible to the user. // Exiting tells them to run `commonly agent detach `. // 3 is deliberate — 1 would churn on a token-rotation race during // reprovision-all; 5+ wastes rate-limit budget after the real-revoke case. let consecutiveAuthErrors = 0; const MAX_AUTH_ERRORS = 3; let consecutivePollFailures = 0; let consecutiveSpawnFailures = 0; const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName); // Per-seat cascade state — lives with the process, like the session store. // A wrapper restart forgets the streak; the decay window covers that gap. const cascadeSettings = resolveCascadeSettings({ overrides: { cap: cascadeCap, addressedGrace: cascadeAddressedGrace, resetMs: cascadeResetMs, }, warn: (message) => log(`cascade config: ${message}`), }); const cascadeGovernor = createCascadeGovernor(cascadeSettings); // Unconditional, because the only other cascade line on this path is the // warn callback above — which fires only when a value is BAD. Without this, // a seat running COMMONLY_CASCADE_CAP=8 logs exactly what a seat on the // shipped default logs, and an env var (unlike the source edit it replaces) // leaves no git evidence of which seat diverged. The `(defaults)` marker // makes "show me every retuned seat" a grep for its absence. const cascadeIsDefault = Object.keys(CASCADE_DEFAULTS) .every((key) => cascadeSettings[key] === CASCADE_DEFAULTS[key]); log( `cascade: cap=${cascadeSettings.cap} grace=${cascadeSettings.addressedGrace} ` + `reset=${cascadeSettings.resetMs}ms${cascadeIsDefault ? ' (defaults)' : ''}`, ); // Fairness: recent broadcast-race winners start the next race from the back. const claimHandicap = createClaimHandicap({ delayMs: claimYieldDelayMs }); // Adapters default `ctx.cwd` to this path. Node's child_process.spawn // rejects with "spawn ENOENT" when cwd does not exist — same shape // as binary-not-found — so we ensure it up front to avoid the confusing // diagnostic. ADR-008: when an environment spec was attached, the resolved // workspace path replaces the /tmp default. const scratchCwd = join(tmpdir(), 'commonly-agents', agentName); const agentCwd = workspacePath || scratchCwd; if (!existsSync(agentCwd)) mkdirSync(agentCwd, { recursive: true }); // TASK-294: a pod can turn public after attach, so a workspace seat re-reads // its pods before every turn, and a turn it cannot confirm (a failed read // included) runs as a public seat in the scratch folder. The session is // bound to where and as what the turn runs, so a public turn never resumes // a conversation that read the workspace, whatever claude's resume would // find from another folder. let lastPlacementReason = null; const placeTurn = async () => { if (seatEnvironment?.sandbox?.trust !== 'workspace') { return { environment: seatEnvironment, cwd: agentCwd, binding: sessionBinding }; } let installations = null; let readError = null; try { const identity = await client.get('/api/agents/runtime/installations', {}, { signal: AbortSignal.timeout(5000), }); installations = Array.isArray(identity?.installations) ? identity.installations : []; } catch (err) { installations = null; readError = err; } const placement = workspaceTurnPlacement({ environment: seatEnvironment, workspacePath: agentCwd, installations, scratchDir: scratchCwd, }); if (placement.reason !== lastPlacementReason) { log(placement.reason ? `this turn runs as a public seat in ${scratchCwd}, because ${placement.reason}` : `every pod is confirmed private again; turns read ${agentCwd}`); lastPlacementReason = placement.reason; } if (!existsSync(placement.cwd)) mkdirSync(placement.cwd, { recursive: true }); const where = `${placement.environment.sandbox.trust}:${placement.cwd}`; return { ...placement, readError, binding: sessionBinding ? `${sessionBinding}:${where}` : where }; }; const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10)); const processEvent = async (event) => { const inventory = await runtimeInventoryPromise; if (reportRuntime && requestedSeatEnvironment && !inventory?.efforts.includes(requestedSeatEnvironment.effort)) { seatEnvironment = { ...requestedSeatEnvironment, effort: undefined }; } const eventPodId = event.podId || podId; const prompt = extractPrompt(event); if (!prompt || !eventPodId) { // No prompt, or nowhere to post the response — skip spawn entirely so // we never consume a CLI turn for a message with no destination. // Surfaced through onError, not just the log: a silently-acked event is // indistinguishable from a swallowed wake (#896 — the pilot burned an // evening on exactly this ambiguity). It is still acked (deliberate // decline, not a retry), but the operator can now see the skip. log(`[${event.type}] no prompt — no-op`); onError?.(Object.assign( new Error( `${event.type} event ${event._id} was acked WITHOUT a spawn: ` + `${!eventPodId ? 'no podId to post into' : 'payload carried no content and no messageId'}. ` + 'If this wake mattered, its producer is sending an unusable payload.', ), { code: 'agent_event_skipped_no_prompt', eventId: event._id }, )); return { outcome: 'no_action', reason: 'no-prompt' }; } // Snapshot the pod's recent messages so that, after the spawn, we can // tell whether the agent posted itself via commonly_post_message. If it // did, its final CLI text is a narration/log — echoing it would duplicate // the message and re-fire any @mention. This is the wrapper-side guarantee // that a deliberate multi-post ("on it…" then "…done") is never doubled, // without relying on the agent to emit NO_REPLY. Against a server that // stamps `self` this is exact; against an older one it degrades to the // legacy any-bot test, where a concurrent post by another agent can // suppress a genuine reply (#757). const snapshotMessages = async () => { try { const { messages = [] } = await client.get( `/api/agents/runtime/pods/${eventPodId}/messages`, { limit: 10 }, ); return messages; } catch { return null; // detection unavailable — fall back to posting the reply } }; // A consult request's final output is routed to the ask-response endpoint, // never echoed into the pod. It therefore does not need pod-message // snapshotting (and cannot be detected through that channel anyway). // The snapshot doubles as the cascade governor's authorship source, so it // runs BEFORE enforcement. const shouldSnapshotMessages = event.type !== 'agent.ask'; const preSpawn = shouldSnapshotMessages ? await snapshotMessages() : null; const preSpawnIds = preSpawn ? new Set(preSpawn.map((m) => String(m._id || m.id))) : null; // ── ADR-018 enforcement: cascade cap ──────────────────────────────────── // Refusing here is a deliberate, permanent decline (acked no_action): a // capped agent-triggered event is exactly the traffic we want dropped. // Human-triggered turns are never capped. const trigger = classifyTrigger(event, preSpawn); // A human turn is a property of the POD, not of this seat's reaction to // it. Record it as soon as it is classified — before the cascade check and // before the claim race — so a seat that stands down still observes the // reset. // // Without this, losing a claim on a human message is SELF-REINFORCING. // The claim stand-down returns before `runTurn`, and `record()` lives at // the end of `runTurn`, so the loser never records the human turn that // would have cleared its streak. It meets the next broadcast still capped, // declines, and again skips `record()`. The only other escape is the // seat's own resetMs clock: stateFor reads only this process's // lastAgentTurnAt, so a capped seat self-clears 10 minutes after its last // completed agent turn regardless of room traffic — cyclical throttling, // not permanent starvation. // // Measured in one pod, 2026-08-18, same window: ux-lead 14 lost claims / // 73 cap refusals / 2 posts, against sprint-review 0 / 2 / 95. Losing one // race is what kept it losing — the mechanism meant to SHARE work // concentrated it instead. // // `=== 'human'` rather than `!== 'agent'`. These are behaviourally the // same today: `record()` dispatches on exact equality and leaves every // other value untouched, including the 'unknown' that `classifyTrigger` // returns for an unresolvable trigger — `enforcement.js:123-131`, and the // "'unknown' is neutral: no count, no reset" note at :130. The narrow form // is defensive, not load-bearing: it keeps an unidentifiable event from // becoming a cap-reset primitive if `record()` ever grows a fallthrough // branch. It closes no hole that was open. // // Agent-triggered turns still record only on completion (see `runTurn`), // so the no-double-count property for redelivered events is unchanged. // Recording a human trigger sets the streak to 0, so this call and the // completion-time one are idempotent with each other. if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger); const admission = cascadeGovernor.admit(eventPodId, trigger, event.type, event.payload); if (!admission.allowed) { // Name the MESSAGE, not just the pod. Without this the refusal is silent at // three ends, not two: the mentioning agent gets no signal its mention died, // an operator reading the log cannot tell which mentions were dropped, and // the suppressed seat cannot say what it ignored — it never saw the id. Two // real mentions were lost this way on 2026-08-19 and the seat they were // addressed to could not enumerate them afterwards. log( `[${event.type}] cascade cap: ${admission.streak} consecutive agent-triggered ` + `turns in pod ${eventPodId}` + (event?.payload?.messageId ? ` (dropped message ${event.payload.messageId})` : '') + (admission.graceApplied ? ' (addressed grace also spent)' : '') // NOT "until the pod goes quiet": other seats' traffic never touches // this seat's clock, and a refusal returns here without reaching // `record()` below — so the window is measured from this seat's last // ADMITTED turn in this pod and runs regardless of how loud the room // is. Saying "the pod goes quiet" told an operator to wait for a lull // that is neither necessary nor sufficient (#989). + ` — standing down until a human speaks or ${cascadeSettings.resetMs}ms ` + 'passes with no admitted turn from this seat in this pod', ); // `details` rides through to the AgentEvent row untouched: // normalizeDeliveryMeta whitelists outcome/reason/messageId and passes // `details` straight into `delivery.details`, typed Schema.Types.Mixed. // So the settings that produced this refusal become queryable with NO // backend change and no deploy. // // This is the half the boot log cannot cover. The log says what a seat // resolved; it does not survive a restart, and nothing joins it to the // refusals it caused. Once cap is per-seat and arbitrary, a refusal // whose cap is unknown cannot be compared against one from another seat. // `reason` deliberately stays the fixed literal 'cascade-cap' — :1111 // matches it with === and the run-loop tests assert it — so the value // goes in a sibling field instead of being spelled into the reason. return { outcome: 'no_action', refused: { reason: 'cascade-cap' }, reason: 'cascade-cap', details: { // The id of what was dropped. `reason` and `streak` say a refusal // happened; only this says WHICH message never got answered, which is // what anyone auditing a missed mention actually needs to join on. messageId: event?.payload?.messageId || null, streak: admission.streak, cap: cascadeSettings.cap, addressedGrace: cascadeSettings.addressedGrace, resetMs: cascadeSettings.resetMs, addressed: admission.addressed, graceApplied: admission.graceApplied, }, }; } // ── ADR-018 enforcement: claim-before-act ─────────────────────────────── // Losing a BROADCAST race is a complete turn (stand down, ack): the // holder owns the conversation, and liveness on holder death comes from // the HOLDER's own event staying unacked, not from ours. Losing while // DIRECTLY ADDRESSED is different — a human chose this seat, and that // outranks being beaten to a CAS: the seat still gets its turn, peer-aware // (add your view only if materially different). Claim-route failures // proceed unguarded — enforcement must never make an agent silent (#887). let claimKeeper = null; let peerFrame = null; const claimMessageId = event.payload?.messageId; if (claimMessageId && CLAIMABLE_EVENT_TYPES.has(event.type)) { // Fairness: the winner of this pod's previous broadcast race enters the // next one after a jittered delay. Everyone still claims — a hard // cooldown could leave a message with NO claimant, which is #887 // self-inflicted — recent winners just start from the back. if (event.type === 'message.posted') { const yieldMs = claimHandicap.yieldDelayMs(eventPodId); if (yieldMs > 0) { log(`[${event.type}] yielding ${yieldMs}ms before claiming — won this pod's previous broadcast race`); await sleepImpl(yieldMs); } } claimKeeper = createClaimKeeper(client, { messageId: claimMessageId, podId: eventPodId, leaseSeconds: claimLeaseSeconds, log: (line) => log(`[${event.type}] ${line}`), setIntervalImpl, clearIntervalImpl, }); const claim = await claimKeeper.acquire(); if (!claim.claimed && !claim.failOpen) { // Per-event EVIDENCE, deliberately not a widening of // ADDRESSED_EVENT_TYPES (that set is a pricing table): the backend // stamps repliesToYourMessage when the woken message replies to or // threads on a message THIS seat authored. Without it, a peer's // claim on its own reply ordered the replied-to author out of its // own conversation (Sage stood down twice on Anvil's thread // replies, 2026-08-24). Interim for TASK-058. const repliesToThisSeat = event.payload?.repliesToYourMessage === true; if (ADDRESSED_EVENT_TYPES.has(event.type) || event.payload?.addressed === true || repliesToThisSeat) { log( `[${event.type}] message ${claimMessageId} held by ${claim.holder} — ` + (repliesToThisSeat ? 'proceeding peer-aware (the message replies to this seat\'s own message)' : 'proceeding peer-aware (this seat was directly addressed)'), ); peerFrame = peerHoldsFrame(claim.holder, claimMessageId); claimKeeper = null; // nothing held: no renewal, no release, no isLost gate } else { claimHandicap.recordLoss(eventPodId); log(`[${event.type}] message ${claimMessageId} already claimed by ${claim.holder} — standing down`); return { outcome: 'no_action', reason: 'claim-held' }; } } else if (claim.claimed) { if (event.type === 'message.posted') claimHandicap.recordWin(eventPodId); claimKeeper.startRenewal(); } else { log(`[${event.type}] claim unavailable (${claim.error?.message || 'unknown error'}) — proceeding unguarded`); } } let turnResult; try { turnResult = await runTurn({ event, eventPodId, prompt: peerFrame ? `${peerFrame}\n\n${prompt}` : prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger, }); return turnResult; } finally { // A silent, normally completed human wake is an explicit decline, not // a successful answer. Tell the kernel so it can hand the message to // exactly one remaining original listener. A refusal — the upstream // route down, or the server refusing the post — is NAMED (TASK-099): it // carries its class so the kernel's record can tell "answered" from // "never ran", and on a human wake the kernel hands it on exactly as it // does a decline, because a failure local to THIS seat must not make a // human's message disappear. A thrown spawn keeps the legacy release, so // normal at-least-once redelivery still happens after an infrastructure // failure. One decision point, shared with the batch path — see // lib/claim-outcome.js. const release = claimReleaseFor(event, turnResult); await claimKeeper?.release(release.outcome, { reason: release.reason, status: release.status, }); } }; // The spawn → post-decision half of a turn, split out so the claim release // above is a plain finally instead of threading through every return path. const runTurn = async ({ event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger, }) => { const placement = await placeTurn(); // A workspace turn placed public (its pod turned public, or its pods could // not be read) is confined as a public seat, which this host may not be // able to do: Claude Code on Linux has only the workspace jail. Refuse it // as the run start would, before anything is read or spawned. The model // never runs and nothing is posted. A public pod is a settled answer, so a // human's message is handed to another listener (claim-outcome.js). A // failed read is not: it throws, so the event stays unacked and comes back // through the spawn-retry backoff instead of being refused for good. if (placement.reason) { const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name }); if (turnRefusal && placement.readError) { throw Object.assign( new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, { cause: placement.readError, }), { status: placement.readError.status }, ); } if (turnRefusal) { log(`turn refused, because ${placement.reason}: ${turnRefusal.replace(/\n\s*/g, ' ')}`); return { outcome: 'no_action', refused: { reason: 'delivery-refused' }, reason: 'host-cannot-confine-turn', }; } } // TASK-440: a cold, large session starts fresh at wake instead of being // resumed. The restart note leads the prompt; memory still reaches the // fresh session through the adapter's preamble. const sessionEntry = getSessionEntry(agentName, eventPodId, { binding: placement.binding }); let sessionId = getSession(agentName, eventPodId, { binding: placement.binding }); let recycleBriefing = null; if (sessionId && shouldStartFreshSession({ lastTurn: sessionEntry?.lastTurn, contextTokens: sessionEntry?.contextTokens, thresholds: recycleThresholds(), })) { recycleBriefing = buildRecycleBriefing({ agentName, idleMinutes: idleMinutesSince(sessionEntry.lastTurn), contextTokens: sessionEntry.contextTokens, }); log(`session recycled: idle ${idleMinutesSince(sessionEntry.lastTurn)} min, ` + `${sessionEntry.contextTokens} context tokens; starting fresh`); sessionId = null; } // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx, // and (if the adapter returns a summary) patch-sync back after. A workspace // seat's turn that runs public (placement.reason) gets none of it: its // memory may hold what earlier turns read in the project, and what this // turn reads in an unconfirmed room must not reach the next workspace turn. // The adapter also denies the memory tools for it (memoryWithheld). const memoryWithheld = Boolean(placement.reason); const memoryLongTerm = memoryWithheld ? undefined : await readLongTerm(client, { onError }); // Sharpen TASK-129: focus is a turn-start read, not an enqueue-time // snapshot. Read through the authorized runtime context route immediately // before spawn so queued events observe the current revision. The helper // disables pod-skill synthesis and throws on failure; the surrounding // processing path then leaves this event (or every event in this batch) // unacknowledged for normal delivery retry. let focusRead; let focusFrame; try { focusRead = await readPodFocus(client, eventPodId); focusFrame = formatPodFocusFrame(focusRead); } catch (error) { Object.assign(error, { eventId: event?.payload?.batchEventIds || event?._id || null, podId: eventPodId, focusRevision: focusRead?.revision ?? null, allowedCodePoints: error?.allowedCodePoints || FOCUS_FRAME_MAX_CODE_POINTS, }); throw error; } const catchUpFrame = await catchUpBeforeTurn(client, eventPodId); const promptWithFocus = `${focusFrame}\n\n${catchUpFrame}\n\n${prompt}`; let serverSkills; let skillReadUnavailable = false; try { const response = await client.get(`/api/agents/runtime/pods/${eventPodId}/skills`, {}, { signal: AbortSignal.timeout(5000), }); serverSkills = compileServerSkills(response?.items || []); } catch (error) { // Skill delivery is optional to the turn. A read failure must not stop // every seat from spawning; report the missing observation explicitly. if (error?.status !== 404) { skillReadUnavailable = true; log(`server skills unavailable (HTTP ${error?.status || 'no response'}); spawning without them`); } serverSkills = compileServerSkills(); } const promptWithSkills = serverSkills.text ? `${serverSkills.text}\n\n${promptWithFocus}` : promptWithFocus; const promptForSpawn = recycleBriefing ? `${recycleBriefing}\n\n${promptWithSkills}` : promptWithSkills; if (adapter.requiresModel) { try { requireSeatModel(seatEnvironment, adapter.name, true); } catch (error) { if (error.code === 'seat_model_missing' && eventPodId && !missingModelNotices.has(eventPodId)) { try { const delivery = await deliverChatReply({ client, podId: eventPodId, text: "This agent has no model set; add environment.model to its configuration.", limit: chatCharLimit, maxChunks: maxChatChunks, log, }); if (!delivery.skipped && !delivery.refused) missingModelNotices.add(eventPodId); } catch (noticeError) { log(`missing model notice could not be posted: ${noticeError.message}`); } } // Configuration failure is retryable: never turn a notice into an ack. throw error; } } log(`[${event.type}] spawning ${adapter.name}`); // TASK-102 part B. The CHILD gets a scoped credential; the wrapper keeps // the seat token for its own polling, claiming and posting (`client` was // built from `token` above and is not touched here). The lease renews while // the child runs and revokes when it returns, so the window in which a // leaked child credential is usable is one turn rather than a seat's life. // // A refusal throws: `openSpawnCredential` only falls back to the seat token // for capacity, and a verdict (401/403/400/404/409) must fail the spawn // rather than restore the authority the refusal was about. The catch below // turns that throw into a NAMED refusal on the turn result, so the claim is // released as `delivery-refused` instead of being re-delivered forever. const spawnPolicyOrNull = spawnPolicyForAsk(); const lease = spawnCredentialLeaseFactory({ client, seatToken: token, spawnId: buildSpawnId({ agentName, eventId: event?._id || event?.payload?.batchEventIds || null }), desiredTtlSeconds: spawnCredentialTtlSeconds, policy: spawnPolicyOrNull === null ? null : await spawnPolicyOrNull, log, }); let opened; try { opened = await lease.open(); } catch (err) { // A VERDICT IS NOT A CRASH, AND IT IS NOT A NO-OP (wren 72153, vera // 72157). Letting it escape as a bare throw left `turnResult` undefined, // so `claimReleaseFor` returned the legacy holder-only DELETE — right for // a transient failure, wrong for a refusal that recurs on every // redelivery: the wake loops forever with one local log line as its only // record. Naming it on the turn result is what lets the release carry the // class, and on a human wake lets the kernel hand the message to a // remaining listener instead of stranding it. if (!err?.spawnCredentialRefused) { // ONLY a verdict is named as a refusal. An unexpected failure inside // the lease — a bug in our own code, not the server's answer — keeps // the legacy release, because `delivery-refused` would hand a human's // message away and close the row on the strength of our own defect. // Redelivery is the right remedy for a crash. log(`spawn credential lease failed (${err?.message ?? err}) — not spawning`); throw err; } const status = Number.isInteger(err.status) ? err.status : null; const code = typeof err.body?.code === 'string' ? err.body.code : null; log(`spawn credential refused (HTTP ${status ?? 'no response'}) — not spawning`); // `delivery-refused` claims the least of the three classes: the model // never ran, and the refuser is our own mint rather than the model // provider (that would be `upstream-refused`, and its status is the HTTP // status of an upstream call — a meaning this refusal does not have, so // the number travels in the ack instead of on the claim record). return { outcome: 'no_action', refused: { reason: 'delivery-refused' }, reason: `spawn-credential-refused-${status ?? 'unknown'}`, details: { ...(status !== null ? { status } : {}), ...(code !== null ? { code } : {}), }, }; } let result; let skillMount = skillReadUnavailable || serverSkills.mounted.length || serverSkills.conflicted.length ? { mounted: serverSkills.mounted, conflicted: serverSkills.conflicted, ...(skillReadUnavailable ? { status: 'unavailable' } : {}) } : null; let spawnCompleted = false; let observedSpawn = false; let commitAttribution = null; try { // The local CLI is a wrapper for a Commonly seat, so its Git commits // carry the seat and the adapter's selected model/effort. `stub` exists // only for tests and has no commit-making child process. if (adapter?.name !== 'stub') { commitAttribution = prepareCommitAttribution({ cwd: placement.cwd, env: process.env, agentName, displayName, instanceId, adapter: adapter?.name, model: seatEnvironment?.model, effort: seatEnvironment?.effort, }); } await runtimeConnected; let providerKey; if (reportRuntime && resolveOwnProviderKey) { try { providerKey = await resolveOwnProviderKey({ opened, harness: adapter.name, provider: seatEnvironment?.provider }); } catch (error) { if (!error.ownKeySpawnRefused) throw error; return { outcome: 'no_action', refused: { reason: 'delivery-refused' }, reason: 'own-key-requires-scoped-spawn' }; } } result = await adapter.spawn(frameDecisionForkRule(promptForSpawn), { onSpawn: reportRuntime ? (controls) => { observedSpawn = true; return runtimeReporter.spawn(controls); } : undefined, providerKey, timeoutMs: effectiveTurnTimeoutMs, sessionId, cwd: placement.cwd, skillMountDir: scratchCwd, env: commitAttribution?.env || process.env, commitAttributionEnv: commitAttribution?.sandboxEnv || null, memoryLongTerm, memoryWithheld, environment: placement.environment, onSkillsLinked: ({ mounted, conflicted }) => { skillMount = { mounted: [...new Set([...serverSkills.mounted, ...mounted.map(reportedSkillName)])], conflicted: [...new Set([...serverSkills.conflicted, ...conflicted.map(({ path }) => reportedSkillName(path))])], ...(skillReadUnavailable ? { status: 'unavailable' } : {}), }; }, // Runtime context the Claude/Codex adapters expose only to their // per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders // literal on disk and lets its native MCP parser expand them, so the // bearer token never enters the generated config file. Since TASK-102 // part B this is the per-spawn credential when one could be minted, and // the seat token only when the server could not mint. runtimeToken: opened.token, instanceUrl, agentName, metadata: { event }, }); spawnCompleted = true; } finally { try { if (reportRuntime) await runtimeReporter.settled(observedSpawn ? spawnCompleted ? 'completed' : 'failed' : null); commitAttribution?.cleanup(); } finally { if (spawnCompleted && skillMount) { try { await client.post(`/api/agents/runtime/pods/${eventPodId}/skill-load`, skillMount); } catch (error) { log(`skill load status could not be recorded: ${error.message}`); } } // Best-effort by construction: `close` logs and returns on a failed // revoke, so a turn that already produced an answer is not failed by // cleanup. The boot sweep collects anything left behind. await lease.close(); } } if (result.newSessionId) { // TASK-440: record the context size this turn left behind, so the next // wake can decide whether to recycle. Adapters that report nothing keep // resuming. setSession(agentName, eventPodId, result.newSessionId, { binding: placement.binding, contextTokens: result.contextTokens ?? null, }); } // Decide whether to post the CLI's final text. The agent owns its posting: // if it already spoke this turn through commonly_post_message, echoing its // output would double-post. Two independent signals suppress the echo: // 1. Detection — a new message appeared in the pod during the spawn (the // agent posted via the tool). Automatic; no agent cooperation needed. // 2. NO_REPLY — the agent explicitly asks us to stay silent. Honored only // when it's the ENTIRE reply; mixed content still posts verbatim. // Otherwise (a bare CLI with no posting tools, or an agent that chose to // reply via its output) the wrapper delivers the text as before. const replyText = (result.text || '').trim(); let agentPostedItself = false; let suppressedBy = null; if (preSpawnIds) { const postSpawn = await snapshotMessages(); if (postSpawn) { // The server stamps each message with `self` when it knows who is // asking. Trust that over any local guess: the wrapper cannot derive // its own bot username reliably (the server applies LEGACY_AGENT_MAP // and an owner-scoped instanceId), and a wrong guess double-posts. const serverKnowsSelf = postSpawn.some((m) => typeof m.self === 'boolean'); for (const m of postSpawn) { if (preSpawnIds.has(String(m._id || m.id))) continue; // A new human-authored message must NEVER suppress the echo: it is // either a human typing mid-turn, or the agent misusing an operator // CLI profile / human token (the 2026-07-22 as-operator attribution // incident) — in both cases the wrapper still delivers the reply // under the agent's own identity. if (!m.isBot) continue; // With `self`, only THIS agent's own post suppresses. Without it (an // older server), fall back to the legacy any-bot test — which drops // this agent's reply whenever another agent answers first (#757). if (serverKnowsSelf ? m.self === true : true) { agentPostedItself = true; suppressedBy = { id: String(m._id || m.id), author: m.username || 'unknown', basis: serverKnowsSelf ? 'self' : 'legacy-any-bot', }; break; } } } } const heartbeatControlReply = (event.type === 'heartbeat' || event.payload?.hasHeartbeat === true) && /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText); const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply; // A refused model route is not a silent turn. pi reports the refusal on its // own JSON stream and exits 0 with no text, so before this the seat log read // `no wrapper-post (empty output)` and the cause — a 429 budget message — was // nowhere (TASK-096; two and a half days of this on one seat). const upstream = result.upstream || null; let delivered = agentPostedItself; let deliveryRefusal = null; if (event.type === 'agent.ask') { if (silentReply) { const reason = heartbeatControlReply ? replyText : (replyText || 'empty output'); // The same naming rule as the chat path below: an ask that went // unanswered because the route refused must not read as the agent // having nothing to say. if (upstream) log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — no private response`); else log(`[${event.type}] no private response (${reason})`); } else { try { await client.post( `/api/agents/runtime/asks/${encodeURIComponent(event.payload.requestId)}/respond`, { content: replyText }, ); delivered = true; log(`[${event.type}] routed private response (${Buffer.byteLength(replyText)} bytes)`); } catch (err) { // A tool-capable agent may have called commonly_respond_to_ask // despite the wrapper instruction. Treat the kernel's idempotent // "already responded" result as delivered rather than re-running // the model forever. if (err?.status === 409 && err?.body?.code === 'already_responded') { delivered = true; log(`[${event.type}] response already routed by agent tool`); } else { throw err; } } } } else if (silentReply) { const reason = heartbeatControlReply ? replyText : (replyText || 'empty output'); // A turn that posted via tool and THEN ended with the sentinel is not the // same event as a turn that produced nothing — but this branch reported // both as `no wrapper-post (NO_REPLY)`, because it is evaluated before // the `agentPostedItself` branch below and swallowed that fact. // // The skill told agents to do exactly this (post via commonly_post_message, // then end with NO_REPLY so the wrapper does not double-post), so CORRECT // behaviour and total silence were indistinguishable on stdout. // // Cost, 2026-08-18: a seat was diagnosed as mute for "19 hours" on the // strength of `grep -c "posted via tool" == 0` against its log. It had in // fact posted seven times in that window. Five remedies were applied to a // seat that was never broken — cleared session, fresh process, MCP // repointed, model repinned — before the seat itself pointed at the // ledger. The instrument was broken, not the agent. // // Report the two cases distinctly. `posted` is the load-bearing word: a // reader scanning for whether a seat is contributing must not have to // infer it from the absence of a different line. if (agentPostedItself) { log( `[${event.type}] posted via tool, then ${reason} — no echo needed ` + `(matched message ${suppressedBy.id} by ${suppressedBy.author} ` + `via ${suppressedBy.basis})`, ); } else if (upstream) { // The status is always named; the body only when the keep-list kept one. // No retry is added: the adapter's own ladder has already run, and a // refused route is refused on the next attempt too. log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — nothing posted this turn`); } else { log(`[${event.type}] no wrapper-post (${reason}) — nothing posted this turn`); } } else if (agentPostedItself) { // Name the message that caused the suppression. A silently dropped reply // is invisible to everyone; #757 went unnoticed precisely because this // line said only "posted via tool" with no way to tell whose post it saw. log( `[${event.type}] agent posted via tool this turn — not echoing CLI output ` + `(avoids double-post; matched message ${suppressedBy.id} by ${suppressedBy.author} ` + `via ${suppressedBy.basis})`, ); } else if (claimKeeper?.isLost()) { // ADR-018 D3: the lease lapsed mid-turn and a peer re-won the message — // that peer owns the conversation now, so posting would recreate the // two-agents-one-message crossing the claim exists to prevent. log( `[${event.type}] stood down — claim lost mid-turn to ${claimKeeper.getHolder()}; ` + `reply suppressed (${Buffer.byteLength(replyText)} bytes not posted)`, ); } else { // ADR-018 length gate: the tone contract is enforced here, where the // wrapper is the one posting. Split, attach — never truncate. let delivery; try { delivery = await deliverChatReply({ client, podId: eventPodId, text: replyText, limit: chatCharLimit, maxChunks: maxChatChunks, uploadName: `${agentName}-reply-${event._id}.md`, log: (line) => log(`[${event.type}] ${line}`), }); } catch (deliveryError) { // TASK-535: every rung threw, so the reply reached nobody. The throw // still leaves the events unacked for redelivery (unchanged), but the // retry report is about the BATCH; this line is about the reply, and // it names where it was going so a lost summary is never silent. const eventCount = Array.isArray(event.payload?.batchEventIds) ? event.payload.batchEventIds.length : 1; log( `[${event.type}] ERROR reply NOT delivered to pod ${eventPodId} — ` + `${Buffer.byteLength(replyText)} bytes lost, ${eventCount} event${eventCount === 1 ? '' : 's'} ` + `left unacked for retry: ${deliveryError.message}`, ); throw deliveryError; } if (delivery.skipped) { // The server answered 200 and created nothing: a duplicate inside its // dedupe window, a silent body, a heartbeat it keeps. Nothing reached // the pod, so this is not a delivery and must not read as one in the // log — "posted 151 bytes as 1 message" over a skip is how a silent // seat looked like a talking one (hq-support, 2026-10-09 08:10Z). Ack // it like a refusal: re-posting the same text inside the window would // only be skipped again. deliveryRefusal = delivery; const windowMinutes = delivery.duplicate?.dedupeWindowMinutes; const detail = [ windowMinutes ? `${windowMinutes} min window` : null, delivery.duplicate?.id ? `duplicate of ${delivery.duplicate.id}` : null, delivery.attemptedMessages > 1 ? `after ${delivery.messages}/${delivery.attemptedMessages} messages` : null, ].filter(Boolean).join(', '); log(`[${event.type}] server skipped the post: ${delivery.reason}${detail ? ` (${detail})` : ''} — nothing reached the pod`); onError?.(Object.assign( new Error(`${event.type} server skipped the post: ${delivery.reason}`), { code: 'agent_delivery_skipped', reason: delivery.reason, eventId: event._id, postedMessages: delivery.messages, attemptedMessages: delivery.attemptedMessages, }, )); } else if (delivery.refused) { // A run-cap refusal is a successful HTTP request but not a delivery. // Ack it so the kernel does not replay the same text (the server // guidance expressly says not to retry unchanged), while preserving // the refusal and its partial-post count for the seat and event ledger. deliveryRefusal = delivery; const guidance = delivery.guidance || 'The server refused this message; do not retry it unchanged.'; const detail = `after ${delivery.messages}/${delivery.attemptedMessages} message${delivery.attemptedMessages === 1 ? '' : 's'}`; log(`[${event.type}] wrapper delivery refused ${detail} (${delivery.reason}): ${guidance}`); onError?.(Object.assign( new Error(`${event.type} wrapper delivery refused ${detail}: ${guidance}`), { code: 'agent_delivery_refused', reason: delivery.reason, eventId: event._id, postedMessages: delivery.messages, attemptedMessages: delivery.attemptedMessages, }, )); } else { delivered = true; log( `[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as ` + `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`, ); } } if (result.memorySummary && !memoryWithheld) { try { await syncBack(client, { summary: result.memorySummary }); log(`[${event.type}] memory synced (${Buffer.byteLength(result.memorySummary)} bytes)`); } catch (err) { // Memory-sync failure is non-fatal: the turn already posted, and the // next spawn will re-read from the kernel anyway. Surface, don't // throw. Preserve the original error via `cause` so the stack trace // survives for CLI debugging. onError?.(new Error(`memory sync failed: ${err.message}`, { cause: err })); } } // The turn completed — count it toward (or reset) the pod's cascade // streak. Recording only on completion means a spawn failure that gets // redelivered never double-counts toward the cap. cascadeGovernor.record(eventPodId, trigger); if (deliveryRefusal) { return { outcome: 'no_action', // Kernel class, from REFUSAL_REASONS: a skip is the server declining to // deliver, same class as a refusal; the specific reason rides below. refused: { reason: 'delivery-refused' }, reason: deliveryRefusal.reason, details: { mode: deliveryRefusal.mode, postedMessages: deliveryRefusal.messages, attemptedMessages: deliveryRefusal.attemptedMessages, ...(typeof deliveryRefusal.consecutive === 'number' ? { consecutive: deliveryRefusal.consecutive } : {}), ...(deliveryRefusal.guidance ? { guidance: deliveryRefusal.guidance } : {}), }, }; } // A refusal is not a decline, and it is not a close either. `no_action` // with no reason means "the agent chose not to answer" and hands a human // wake to one remaining listener; nothing about the agent's choice happened // here — the model never ran — so the release says WHICH class of refusal // it was (TASK-099). The kernel routes from there: on a human wake the // message is handed to one remaining listener exactly as a decline is // (this seat's upstream route being down says nothing about theirs), and on // any other wake it is terminal. The free-text detail stays in the seat // log and the event ack; the kernel records the class. if (!delivered && upstream) { return { outcome: 'no_action', refused: { reason: 'upstream-refused', ...(Number.isInteger(upstream.status) ? { status: upstream.status } : {}), }, reason: `upstream-refused-${upstream.status}`, details: { status: upstream.status, ...(upstream.detail ? { detail: upstream.detail } : {}), }, }; } // Empty output is not the sentinel. A seat that answered `NO_REPLY` read // its batch and chose silence; one that printed nothing may have crashed, // timed out or lost its route without saying so. The bare `no_action` // stays the sentinel's alone, because a digest counts as read only on // that (sable, #2753). Release, hand-off and spawn counting treat // `empty-output` exactly as they treat a bare decline. if (!delivered && !replyText) return { outcome: 'no_action', reason: EMPTY_OUTPUT }; return { outcome: delivered ? 'posted' : 'no_action' }; }; // ADR-024 D3: the batch header states the real inbox count even when we cap // copied bodies. The cap is the fetched page itself: never acknowledge a // claimed event whose body the one turn did not receive. Additional pending // events stay in the kernel queue and are cued in the header for a later // pull, rather than being silently consumed here. const buildInboxPrompt = (entries, inboxCount = entries.length) => { const BODY_LIMIT = batchLimit; const wakeEntries = entries.filter((entry) => entry.event.deliveryMode !== 'digest'); const digestEntries = entries.filter((entry) => entry.event.deliveryMode === 'digest'); const bodies = wakeEntries.slice(0, BODY_LIMIT).map((entry, index) => { const messageId = entry.event.payload?.messageId; const prefix = [ `Event ${index + 1}/${wakeEntries.length}: ${entry.event.type}`, messageId ? `message ${messageId}` : null, ].filter(Boolean).join(' — '); const claimContext = entry.readOnly ? `${peerHoldsFrame(entry.holder, messageId)}\n` + '[This item is read-only context: a peer won its binding claim. Do not act on it.]\n' : (entry.peerFrame ? `${entry.peerFrame}\n` : ''); const body = entry.prompt || '[No usable prompt was delivered for this item.]'; const deliveryId = entry.event.payload?.deliveryId; const checkpoint = typeof deliveryId === 'string' && deliveryId ? `\n[After completed tool-side work or intentional NO_REPLY for only this item, call commonly_checkpoint_event(${JSON.stringify({ eventId: String(entry.event._id), deliveryId, })}). Checkpoint metadata is private runtime data; never post it in chat.]` : ''; return `${prefix}\n${claimContext}${body}${checkpoint}`; }); const rollup = wakeEntries.find((entry) => entry.event.payload?.digestRollup)?.event.payload?.digestRollup; const digestFrame = formatDigestFrame(digestEntries.map((entry) => entry.event), rollup); const omitted = Math.max(0, inboxCount - entries.length); return [ `[Inbox batch: ${inboxCount} new event${inboxCount === 1 ? '' : 's'} in this pod. ` + 'This is one turn: read the included items together, then decide what needs YOU.]', 'After tool-side work is committed (including any reply posted through a tool), or an intentional NO_REPLY, checkpoint that item with its supplied IDs. ' + 'Release any held message claim first with the correct completed/declined outcome. ' + 'Do not checkpoint a reply you are holding for final CLI output: the wrapper must post it before acknowledging that item. ' + 'Never checkpoint unfinished work. Only a successful checkpoint prevents replay after timeout. ' + 'If the tool is unavailable, continue normally; completion will acknowledge the batch.', ...bodies, ...(digestFrame ? [digestFrame] : []), ...(omitted > 0 ? [`[${omitted} more event${omitted === 1 ? '' : 's'} arrived. Their count is real; pull pod context if needed.]`] : []), 'Do not narrate the inbox. Post only an action or a materially useful response; otherwise return NO_REPLY. ' + FINAL_TEXT_IS_POSTED, ].join('\n\n'); }; const batchAdmissionResult = (event, admission) => ({ outcome: 'no_action', refused: { reason: 'cascade-cap' }, reason: 'cascade-cap', details: { messageId: event?.payload?.messageId || null, streak: admission.streak, cap: cascadeSettings.cap, addressedGrace: cascadeSettings.addressedGrace, resetMs: cascadeSettings.resetMs, addressed: admission.addressed, graceApplied: admission.graceApplied, }, }); // Claim the binding portion before the ONE composite spawn. A lost binding // claim is not discarded: it remains in the model's context with the same // peer frame the single-event path already uses, but is explicitly read-only. // Advisory losses retain their existing peer-aware behaviour. // A refused item is acked, never retried (lib/content-refusal.js). Every one // is logged by event id alone. One addressed to this seat also gets a pod // notice the WRAPPER writes, since the model never read it: identifiers // only, and the hand-off agent @mentioned outside a DM. A refused broadcast // gets no notice: its claim releases as `upstream-refused`, and the kernel // hands a human's message to another listener. A notice that fails to post // is logged; the ack still happens, because retrying would only re-send the // refused content. const postRefusalNotice = async (refusedEntries, eventPodId) => { for (const entry of refusedEntries) { log(`[${entry.event.type}] the model provider refused the content of event ${entry.event._id} (content_refused) — acked as refused, not retried`); } const addressed = refusedEntries.filter((entry) => ADDRESSED_EVENT_TYPES.has(entry.event.type)); if (addressed.length === 0) return; const mention = Boolean(handoffAgent) && !addressed.some((entry) => entry.event.type === 'dm.message'); const text = composeRefusalHandoff({ items: addressed.map((entry) => refusedItemRef(entry.event)), handoff: handoffAgent, mention, }); let posted = false; try { const delivery = await deliverChatReply({ client, podId: eventPodId, text, limit: chatCharLimit, maxChunks: maxChatChunks, uploadName: `${agentName}-refusal-${addressed[0].event._id}.md`, log: (line) => log(`[inbox.batch] ${line}`), }); posted = !delivery.skipped && !delivery.refused; log(posted ? `[inbox.batch] refusal notice posted${mention ? `, handed off to @${handoffAgent}` : ''}` : `[inbox.batch] refusal notice not delivered (${delivery.reason})`); } catch (err) { log(`[inbox.batch] refusal notice failed: ${err.message}`); } for (const entry of addressed) { entry.result.details = { ...entry.result.details, handoff: mention ? handoffAgent : null, noticePosted: posted, }; } }; // The greeting is CLAIMED the moment a poll returns it: claimPage reads // `status: 'pending'` and marks each returned row `delivered`, and later // polls read only pending. Leaving it unacked therefore does NOT bring it // back next tick — garbageCollect requeues delivered rows only after // AGENT_EVENT_REQUEUE_DELIVERED_MINUTES (10 by default, and its own comment // notes the effective latency is worse). So the hold is in memory, not on // the wire: carry the claimed event and prepend it to the next poll's batch. // Spent at most once per run. (CTO review 5623358693.) let heldGreeting = []; let greetingHoldSpent = false; // The same is true of a batch whose turn FAILED: its events are claimed, so // scheduling a later poll retries nothing — the next poll cannot return // them, and the old "retry scheduled" was really a 10–20 minute wait for the // requeue sweep with the process idle in between (sprint-review, // 2026-10-25). So a failed turn's events are carried exactly like the // greeting and rejoin the inbox when the retry timer fires. Their leases are // renewed while they wait, because an ack under a lapsed lease is a 409 // `stale_delivery`, and that stops the run. The rule (CTO ruling, // 2026-10-25): a scheduled retry either fires, re-running the held events, // or releases them; it never leaves a live process idle on unacked work. let heldRetry = []; // eventId → consecutive local failures, for HELD_RETRY_MAX_FAILURES. const heldRetryFailures = new Map(); let heldRenewTimer = null; const clearHeldRenewal = () => { if (heldRenewTimer !== null) clearIntervalImpl(heldRenewTimer); heldRenewTimer = null; }; // Renew the nonce-bound leases of carried events. Returns the ids the server // confirmed are still this runner's delivery, or null when it cannot say: // nothing carried a nonce, the request failed, or an older server answered // without a `renewed` list. const renewHeldDeliveries = async (events) => { const deliveries = events.filter(deliveryIdOf) .map((event) => ({ eventId: String(event._id), deliveryId: deliveryIdOf(event) })); if (deliveries.length === 0) return null; const confirmed = new Set(); try { for (let offset = 0; offset < deliveries.length; offset += 50) { // eslint-disable-next-line no-await-in-loop const response = await client.post('/api/agents/runtime/events/renew', { events: deliveries.slice(offset, offset + 50), }); if (!Array.isArray(response?.renewed)) return null; for (const id of response.renewed) confirmed.add(String(id)); } } catch (error) { log(`[inbox.batch] held delivery renewal failed: ${error.message}`); return null; } return confirmed; }; // A nonce-bound event the server did not renew is no longer ours to ack: // the child checkpointed it mid-turn, the requeue took it, or the cap // retired it. Re-running it would at best repeat finished work, and at worst // end in a stale-delivery ack that stops the run. const stillHeld = (event, confirmed) => ( confirmed === null || !deliveryIdOf(event) || confirmed.has(String(event._id)) ); // Decide what a failed turn's retry carries. `failedEvents` were attempted // and count toward HELD_RETRY_MAX_FAILURES; `untriedEvents` are later groups // from the same page that the failure stopped before they ran (#782), and // are just as claimed, so they ride along without a strike. const holdForRetry = async (failedEvents, untriedEvents = []) => { const released = []; const carry = []; const seen = new Set(); for (const event of failedEvents) { const id = String(event._id); if (!seen.has(id)) { seen.add(id); const failures = (heldRetryFailures.get(id) || 0) + 1; if (failures >= HELD_RETRY_MAX_FAILURES) released.push(event); else { heldRetryFailures.set(id, failures); carry.push(event); } } } for (const event of untriedEvents) { const id = String(event._id); if (!seen.has(id)) { seen.add(id); carry.push(event); } } // A stopping run carries nothing and renews nothing: extending the lease // of a process on its way out only delays the replacement's redelivery. const confirmed = running && carry.length ? await renewHeldDeliveries(carry) : null; const held = running ? carry.filter((event) => stillHeld(event, confirmed)) : []; const dropped = running ? carry.filter((event) => !stillHeld(event, confirmed)) : []; const heldIds = new Set(held.map((event) => String(event._id))); for (const id of [...heldRetryFailures.keys()]) { if (!heldIds.has(id)) heldRetryFailures.delete(id); } heldRetry = held; if (dropped.length) { log( `[inbox.batch] ${countEvents(dropped.length)} no longer held by this runner on the server ` + '(checkpointed, requeued or retired) — not retried', ); } return { held, released }; }; // Keep a long wait's leases alive. A wait shorter than one renewal interval // needs none: holdForRetry renewed at the start, and the re-run turn renews // its own batch from there. const keepHeldLeases = (delayMs) => { clearHeldRenewal(); if (!running || delayMs <= DELIVERY_RENEW_INTERVAL_MS || !heldRetry.some(deliveryIdOf)) return; heldRenewTimer = setIntervalImpl(async () => { if (!running) { clearHeldRenewal(); return; } const carry = heldRetry; const confirmed = await renewHeldDeliveries(carry); // The tick may already have taken this carry; never resurrect it. if (heldRetry !== carry || confirmed === null) return; heldRetry = carry.filter((event) => stillHeld(event, confirmed)); if (heldRetry.length < carry.length) { log( `[inbox.batch] ${countEvents(carry.length - heldRetry.length)} no longer held by this runner ` + 'on the server during the retry wait — not retried', ); } }, DELIVERY_RENEW_INTERVAL_MS); heldRenewTimer?.unref?.(); }; // What the retry will actually do, for the failure report. const describeHold = ({ held, released }, retry) => { const parts = []; if (retry.circuitOpen) parts.push('circuit open'); if (released.length) { parts.push( `releasing ${countEvents(released.length)} after ${HELD_RETRY_MAX_FAILURES} failed attempts ` + 'to the server\'s requeue (redelivered in ~10–20 min)', ); } parts.push(held.length ? `retrying ${held.length} held event${held.length === 1 ? '' : 's'} in ${formatRetryDelay(retry.delayMs)}` : `next poll in ${formatRetryDelay(retry.delayMs)}`); return parts.join('; '); }; const handleEventBatch = async (events, inboxCount = events.length) => { const eventPodId = events[0]?.podId || podId; if (!eventPodId || events.some((event) => (event.podId || podId) !== eventPodId)) { throw new Error('Inbox batch crossed pod boundaries; refusing to mix private pod context'); } const entries = events.map((event) => ({ event, prompt: extractPrompt(event), result: null, claimKeeper: null, peerFrame: null, readOnly: false, holder: null, })); // Duplicate deliveries have already completed locally. Re-ack them but do // not spend the new batch turn displaying old work as if it were new. const seenEventIds = new Set(); for (const entry of entries) { const eventId = String(entry.event._id); if (seenEventIds.has(eventId) || wasEventHandled(agentName, entry.event._id)) { entry.result = { outcome: 'no_action', reason: 'duplicate-delivery' }; log(`[${entry.event.type}] duplicate delivery ${entry.event._id} — re-acking without batch spawn`); } else { seenEventIds.add(eventId); } if (entry.result?.reason === 'duplicate-delivery') { continue; } if (!entry.prompt) { entry.result = { outcome: 'no_action', reason: 'no-prompt' }; log(`[${entry.event.type}] no prompt — no-op`); onError?.(Object.assign( new Error( `${entry.event.type} event ${entry.event._id} was acked WITHOUT a spawn: ` + 'payload carried no content and no messageId. If this wake mattered, its producer is sending an unusable payload.', ), { code: 'agent_event_skipped_no_prompt', eventId: entry.event._id }, )); } } const snapshotMessages = async () => { try { const { messages = [] } = await client.get( `/api/agents/runtime/pods/${eventPodId}/messages`, { limit: 10 }, ); return messages; } catch { return null; } }; const preSpawn = await snapshotMessages(); const preSpawnIds = preSpawn ? new Set(preSpawn.map((message) => String(message._id || message.id))) : null; const activeEntries = entries.filter((entry) => !entry.result); // Ambient digest entries provide context for this turn, but must never // determine its admission or count as the trigger that woke the seat. const wakingEntries = activeEntries.filter((entry) => entry.event.deliveryMode !== 'digest'); const triggers = wakingEntries.map((entry) => classifyTrigger(entry.event, preSpawn)); const trigger = triggers.includes('human') ? 'human' : (triggers.includes('agent') ? 'agent' : 'unknown'); if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger); const admissionEvent = wakingEntries.find((entry) => ( ADDRESSED_EVENT_TYPES.has(entry.event.type) || entry.event.payload?.addressed === true )) || wakingEntries[0]; if (admissionEvent) { const admission = cascadeGovernor.admit( eventPodId, trigger, admissionEvent.event.type, admissionEvent.event.payload, ); if (!admission.allowed) { for (const entry of wakingEntries) { entry.result = batchAdmissionResult(entry.event, admission); } return { entries }; } } const bindingEntries = activeEntries.filter((entry) => ( entry.event.deliveryMode !== 'digest' && entry.event.type === 'message.posted' && entry.event.payload?.messageId )); if (bindingEntries.length > 0) { const yieldMs = claimHandicap.yieldDelayMs(eventPodId); if (yieldMs > 0) { log(`[inbox.batch] yielding ${yieldMs}ms before claiming ${bindingEntries.length} binding event${bindingEntries.length === 1 ? '' : 's'}`); await sleepImpl(yieldMs); } } for (const entry of activeEntries) { if (entry.event.deliveryMode === 'digest') continue; const messageId = entry.event.payload?.messageId; if (!messageId || !CLAIMABLE_EVENT_TYPES.has(entry.event.type)) continue; const claimKeeper = createClaimKeeper(client, { messageId, podId: eventPodId, leaseSeconds: claimLeaseSeconds, log: (line) => log(`[${entry.event.type}] ${line}`), setIntervalImpl, clearIntervalImpl, }); const claim = await claimKeeper.acquire(); if (claim.claimed) { entry.claimKeeper = claimKeeper; claimKeeper.startRenewal(); if (entry.event.type === 'message.posted') claimHandicap.recordWin(eventPodId); } else if (!claim.failOpen) { entry.holder = claim.holder; if (entry.event.type === 'message.posted') { // A reply to this seat's own message is direct-address evidence, // even though its transport type is the broadcast-shaped // `message.posted`. Preserve the existing ADR-018/TASK-058 // peer-aware exception; D3a otherwise partitions lost bindings as // read-only so a generic broadcast cannot widen itself after CAS. if (entry.event.payload?.repliesToYourMessage === true || entry.event.payload?.addressed === true) { entry.peerFrame = peerHoldsFrame(claim.holder, messageId); continue; } // D3a's partition: no later model judgement can widen this item's // authority after a peer won the binding CAS. entry.readOnly = true; entry.result = { outcome: 'no_action', reason: 'claim-held' }; claimHandicap.recordLoss(eventPodId); } else { entry.peerFrame = peerHoldsFrame(claim.holder, messageId); } } else { log(`[${entry.event.type}] claim unavailable (${claim.error?.message || 'unknown error'}) — proceeding unguarded`); } } const actionEntries = wakingEntries.filter((entry) => !entry.result); if (actionEntries.length === 0) return { entries }; const claimKeepers = actionEntries .map((entry) => entry.claimKeeper) .filter(Boolean); const compositeClaimKeeper = claimKeepers.length > 0 ? { isLost: () => claimKeepers.some((keeper) => keeper.isLost()), getHolder: () => claimKeepers.find((keeper) => keeper.isLost())?.getHolder(), } : null; const batchEvent = { ...actionEntries[0].event, _id: `batch-${actionEntries[0].event._id}`, type: 'inbox.batch', payload: { ...actionEntries[0].event.payload, batchEventIds: entries.map((entry) => String(entry.event._id)), hasHeartbeat: entries.some((entry) => entry.event.type === 'heartbeat'), }, }; // One item, run as its own turn. Used only after the provider refused a // batch's content: a composite turn cannot say WHICH item was refused, and // refusing them all would swallow the ordinary items with the flagged one. const isolatedTurn = async (entry) => { const before = await snapshotMessages(); return runTurn({ event: { ...entry.event, _id: `batch-${entry.event._id}`, type: 'inbox.batch', payload: { ...entry.event.payload, batchEventIds: [String(entry.event._id)], hasHeartbeat: entry.event.type === 'heartbeat', }, }, eventPodId, prompt: buildInboxPrompt([entry], 1), preSpawnIds: before ? new Set(before.map((message) => String(message._id || message.id))) : null, snapshotMessages, claimKeeper: entry.claimKeeper, trigger, }); }; let turnResult; let deferredError = null; try { try { turnResult = await runTurn({ event: batchEvent, eventPodId, prompt: buildInboxPrompt(entries, inboxCount), preSpawnIds, snapshotMessages, claimKeeper: compositeClaimKeeper, trigger, }); for (const entry of actionEntries) entry.result = turnResult; // A digest was context in this successful waking turn, never an // independent action. If the waking work stood down or the provider // refused the batch, leave it unread for the next real turn. A reply // the server skipped as `silent_or_empty` is the seat choosing silence // (the server's answer to an intentional NO_REPLY), so it read the // digest. Empty output (`empty-output`) is not a choice anyone can see: // a broken seat produces it every turn, and reading on it would drain // the backlog that seat never saw. if (turnResult?.outcome === 'posted' || (turnResult?.outcome === 'no_action' && (!turnResult?.reason || turnResult.reason === 'silent_or_empty'))) { for (const entry of activeEntries) { if (entry.event.deliveryMode === 'digest') { entry.result = { outcome: 'no_action', reason: 'digest-read' }; } } } } catch (err) { if (!isContentRefusal(err)) throw err; if (actionEntries.length === 1) { actionEntries[0].result = contentRefusedResult(); } else { log( `[inbox.batch] the model provider refused this batch's content (content_refused); ` + `running its ${actionEntries.length} items one at a time so only a refused item is refused`, ); for (const entry of actionEntries) { if (!running) break; try { // eslint-disable-next-line no-await-in-loop entry.result = await isolatedTurn(entry); } catch (itemErr) { // Any other failure stops here and leaves this item and the rest // unacked for redelivery; the tick applies the retry policy. if (!isContentRefusal(itemErr)) { deferredError = itemErr; break; } entry.result = contentRefusedResult(); } } } await postRefusalNotice( actionEntries.filter((entry) => entry.result?.reason === CONTENT_REFUSED_REASON), eventPodId, ); } return deferredError ? { entries, deferredError } : { entries }; } finally { await Promise.all(actionEntries .filter((entry) => entry.claimKeeper) .map(async (entry) => { // Preserve ADR-018 D6.1 per binding message: a silent human // broadcast may be handed to one remaining listener, a refusal // closes with its reason, and every other completed batch item // closes normally. Same decision point as the single-event path. // An item left without a result (a failure during isolation) // releases with no outcome, for redelivery, as a thrown turn does. const release = claimReleaseFor(entry.event, entry.result || turnResult); await entry.claimKeeper.release(release.outcome, { reason: release.reason, status: release.status, }); })); } }; const processEventBatch = async (events, inboxCount = events.length) => { const deliveries = events.filter((event) => typeof event.payload?.deliveryId === 'string' && event.payload.deliveryId) .map((event) => ({ eventId: String(event._id), deliveryId: event.payload.deliveryId })); let renewing = false; let renewalStopped = false; const renew = async () => { if (renewalStopped || renewing || deliveries.length === 0) return; renewing = true; try { for (let offset = 0; offset < deliveries.length && !renewalStopped; offset += 50) { await client.post('/api/agents/runtime/events/renew', { events: deliveries.slice(offset, offset + 50), }); } } catch (error) { // An older server may lack this route. Never reinterpret a failed // renewal as an ack: stale checkpoint nonces still fail closed. log(`[inbox.batch] delivery renewal failed: ${error.message}`); } finally { renewing = false; } }; const timer = deliveries.length ? setIntervalImpl(renew, DELIVERY_RENEW_INTERVAL_MS) : null; const stopRenewal = () => { renewalStopped = true; if (timer !== null) clearIntervalImpl(timer); }; // Independent of adapter settlement: SIGTERM can be ignored, so awaiting // spawn in finally is not a bound on the event's delivery lease. const deadlineTimer = deliveries.length ? setDeadlineTimeoutImpl(() => { stopRenewal(); log('[inbox.batch] turn deadline reached; delivery renewal stopped for GC recovery'); }, effectiveTurnTimeoutMs) : null; try { return await handleEventBatch(events, inboxCount); } finally { stopRenewal(); if (deadlineTimer !== null) clearDeadlineTimeoutImpl(deadlineTimer); } }; const tick = async () => { if (!running) return; let nextPollDelayMs = intervalMs; let completedModelTurn = false; let retryScheduled = false; try { // ADR-024 D3: this is an inbox fetch, not a stream of interrupts. The // backend returns one pod's oldest pending batch, so every delivered row // below reaches the SAME composite turn before it can age into the // requeue sweep. The service selects the pod before claiming any row; // do not reintroduce a mixed-pod `limit: 10` here. // TASK-317: name this CLI for the chat chip. The server keeps it as a // display-only label (runtime.reportedCli) and never as a binding. const { events = [], inboxCount } = await client.get('/api/agents/runtime/events', { agentName, instanceId, limit: batchLimit, ...(adapter?.name ? { reportedCli: adapter.name } : {}), }); consecutiveAuthErrors = 0; consecutivePollFailures = 0; // A deployed CLI can briefly run against an older server that still // returns multiple pods in one page. Preserve pod privacy in that // migration window by partitioning locally. Current servers produce one // group, so the normal path remains one tick → one turn. // A carried greeting or failed batch rejoins the inbox here, so it is // grouped, ordered and acked by exactly the same path as anything the // poll returned. The carried row is still 'delivered' on the server, so a // requeue during the carry can hand the same id back in this very page: // drop the carried copy rather than processing it twice. The fetched // copy wins because it carries the live deliveryId; the carried one's // nonce was cleared by that requeue. const carried = [...heldRetry, ...heldGreeting]; heldGreeting = []; heldRetry = []; clearHeldRenewal(); const fetchedIds = new Set(events.map((event) => String(event._id))); const carriedIds = new Set(); const carriedFresh = carried.filter((event) => { const id = String(event._id); if (fetchedIds.has(id) || carriedIds.has(id)) return false; carriedIds.add(id); return true; }); const pageEvents = carriedFresh.length ? [...carriedFresh, ...events] : events; const pageInboxCount = (Number.isFinite(inboxCount) ? inboxCount : events.length) + carriedFresh.length; const eventGroups = new Map(); for (const event of pageEvents) { const eventPodId = event.podId || podId; const key = PRIVATE_RESPONSE_EVENT_TYPES.has(event.type) ? `private:${event._id}` : (eventPodId || `private:${event._id}`); const group = eventGroups.get(key) || []; group.push(event); eventGroups.set(key, group); } const groups = [...eventGroups.values()]; // The claimed events a failure at `groupIndex` stops before they run. // A digest-only group was never claimed, so it is not carried. const untriedAfter = (groupIndex) => groups.slice(groupIndex + 1) .filter((later) => !later.every((event) => event.deliveryMode === 'digest')) .flat(); for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { const group = groups[groupIndex]; if (!running) break; // A current kernel never claims a digest-only page. Guard a mixed // version rollout too: an ambient batch must not start a model turn. if (group.every((event) => event.deliveryMode === 'digest')) { log(`[inbox.batch] digest-only page (${group.length}) — no spawn or ack`); continue; } if ((group[0].podId || podId) && !PRIVATE_RESPONSE_EVENT_TYPES.has(group[0].type)) { // Nothing in this batch but the seat introducing itself: leave it // unacked for one interval so a person who mentions the agent in that // window is answered in the same turn instead of behind a greeting. // Spent once per run, so a quiet connect still greets on the next tick. if (!greetingHoldSpent && group.every((event) => DEFERRABLE_SOLO_EVENT_TYPES.has(event.type))) { greetingHoldSpent = true; heldGreeting = group; log(`[inbox.batch] carrying ${group.length} greeting event(s) to the next poll in case someone is mid-sentence`); continue; } // One failure report for a batch whose items stay unacked, whether // the composite turn threw or an isolated item did after a refusal. // The unacked items are carried to the retry, not left to the // requeue sweep, so the delay below is when they run again. const scheduleBatchRetry = async (err, failedEvents) => { consecutiveSpawnFailures += 1; const retry = spawnRetryPolicy({ error: err, consecutiveFailures: consecutiveSpawnFailures, intervalMs, jitterRatio: spawnJitterRatio, }); nextPollDelayMs = retry.delayMs; retryScheduled = true; const hold = await holdForRetry(failedEvents, untriedAfter(groupIndex)); keepHeldLeases(retry.delayMs); const event = group[0]; const wrapped = new Error( `inbox batch processing failed (${retry.failureClass}; ${consecutiveSpawnFailures} consecutive) ` + `— ${describeHold(hold, retry)}: ${err.message}`, { cause: err }, ); Object.assign(wrapped, { code: 'agent_spawn_retry_scheduled', failureClass: retry.failureClass, consecutiveFailures: consecutiveSpawnFailures, retryAfterMs: retry.delayMs, circuitOpen: retry.circuitOpen, heldCount: hold.held.length, releasedCount: hold.released.length, eventId: event._id, ...(isPodFocusDiagnostic(err) ? { focusDiagnostic: { errorCode: err.code, eventIds: err.eventId || group.map((entry) => entry._id), podId: err.podId || group[0]?.podId || podId || null, focusRevision: err.focusRevision ?? null, measuredCodePoints: err.measuredCodePoints ?? null, allowedCodePoints: err.allowedCodePoints ?? FOCUS_FRAME_MAX_CODE_POINTS, }, } : {}), }); if (onError) onError(wrapped); else log(`[inbox.batch] ${wrapped.message}`); }; let batch; try { // The person's item leads the prompt; the greeting rides along. batch = await processEventBatch( [...group].sort((a, b) => inboxPriority(a) - inboxPriority(b)), pageInboxCount, ); } catch (err) { await scheduleBatchRetry(err, group); break; } const batchSpawned = batch.entries.some((entry) => ( entry.result?.outcome === 'posted' || (entry.result?.outcome === 'no_action' && (!entry.result?.reason || entry.result.reason === EMPTY_OUTPUT)) )); if (batchSpawned) { consecutiveSpawnFailures = 0; // Notices arriving mid-turn get one bounded collection window. // Idle polls and batches with no model turn keep their cadence. completedModelTurn = true; } for (const entry of batch.entries) { const event = entry.event; // No result: an item a failure during refusal isolation never // reached. Unacked, the kernel redelivers it. if (!entry.result) continue; if (entry.result?.reason !== 'duplicate-delivery') { recordHandledEvent(agentName, event._id); } try { const deliveryId = event.payload?.deliveryId; await client.post(`/api/agents/runtime/events/${event._id}/ack`, { result: ackResultFor(entry.result), ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}), }); } catch (ackErr) { const terminalError = terminalDeliveryAckError(ackErr, event._id, 'agent run'); if (terminalError) { running = false; onError?.(terminalError); break; } onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`)); } } if (batch.deferredError) { await scheduleBatchRetry( batch.deferredError, batch.entries.filter((entry) => !entry.result).map((entry) => entry.event), ); break; } continue; } const [event] = group; let result; if (wasEventHandled(agentName, event._id)) { result = { outcome: 'no_action', reason: 'duplicate-delivery' }; log(`[${event.type}] duplicate delivery ${event._id} — skipping spawn and re-acking`); } else { const eventWillSpawn = Boolean(extractPrompt(event) && (event.podId || podId)); try { result = await processEvent(event); } catch (err) { if (isContentRefusal(err)) { // A refused private item is acked without a pod notice: it has no // pod to post into, and its sender's ask expires on its own. result = contentRefusedResult(); log(`[${event.type}] the model provider refused the content of event ${event._id} (content_refused) — acked as refused, not retried`); } else { // Do not record or ack: the kernel must retain the event for // at-least-once delivery. Stop this fetched batch immediately — // continuing could launch every one of the 10 returned events // into the same provider outage before the next poll (#782). consecutiveSpawnFailures += 1; const retry = spawnRetryPolicy({ error: err, consecutiveFailures: consecutiveSpawnFailures, intervalMs, jitterRatio: spawnJitterRatio, }); nextPollDelayMs = retry.delayMs; retryScheduled = true; // Carried to the retry like a failed batch: the event is claimed, // so the next poll would not return it. // eslint-disable-next-line no-await-in-loop const hold = await holdForRetry([event], untriedAfter(groupIndex)); keepHeldLeases(retry.delayMs); const wrapped = new Error( `${event.type} processing failed (${retry.failureClass}; ` + `${consecutiveSpawnFailures} consecutive) — event ${event._id} unacked; ` + `${describeHold(hold, retry)}: ${err.message}`, { cause: err }, ); Object.assign(wrapped, { code: 'agent_spawn_retry_scheduled', failureClass: retry.failureClass, consecutiveFailures: consecutiveSpawnFailures, retryAfterMs: retry.delayMs, circuitOpen: retry.circuitOpen, heldCount: hold.held.length, releasedCount: hold.released.length, eventId: event._id, ...(isPodFocusDiagnostic(err) ? { focusDiagnostic: { errorCode: err.code, eventIds: err.eventId || event._id, podId: err.podId || event.podId || podId || null, focusRevision: err.focusRevision ?? null, measuredCodePoints: err.measuredCodePoints ?? null, allowedCodePoints: err.allowedCodePoints ?? FOCUS_FRAME_MAX_CODE_POINTS, }, } : {}), }); // ONE emission, not two. `wrapped.message` already opens with the // event type, so the log copy added a second prefix and a second // line of identical text into the same merged stream — enough that // `grep -c 'session limit'` returned 8 for 4 failures, and a reader // counting hits per event id saw 2 and inferred two deliveries. // On 2026-08-18 that count pointed at the wrong cause for #993: // two deliveries per event puts `attempts` at 2 and the requeue cap // one step away. It did not decide anything — the cap was ruled out // from the DB, where a capped event would have left a `failed` row // and none existed — but it corroborated the wrong theory, and the // doubling had to be spotted by hand before the count could be // discounted. A log that inflates is worse than one that is silent, // because it argues. // // Routed to the error channel when a caller provides one, and to // the log when it doesn't, so neither contract loses the failure: // an embedder that passes no `onError` still sees it, and `agent // run` — which always passes one — stops printing it twice. if (onError) onError(wrapped); else log(`[${event.type}] ${wrapped.message}`); break; } } // Only a completed model turn proves the local runtime and delivery // path recovered. A malformed/no-destination event is still acked, // but must not erase the failure streak without exercising either — // and neither may an enforcement stand-down (cascade cap, lost // claim), which returns before any spawn happens. const stoodDown = result?.reason === 'cascade-cap' || result?.reason === 'claim-held'; if (eventWillSpawn && !stoodDown) consecutiveSpawnFailures = 0; // Record after successful processing but before ack. If the ack // fails, the next delivery is skipped and re-acked instead of // burning a second model turn for work that already completed. recordHandledEvent(agentName, event._id); } try { const deliveryId = event.payload?.deliveryId; await client.post(`/api/agents/runtime/events/${event._id}/ack`, { result: ackResultFor(result), ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}), }); } catch (ackErr) { const terminalError = terminalDeliveryAckError(ackErr, event._id, 'agent run'); if (terminalError) { running = false; onError?.(terminalError); return; } onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`)); } } } catch (err) { if (err?.status === 401 || err?.status === 403) { consecutiveAuthErrors += 1; if (consecutiveAuthErrors >= MAX_AUTH_ERRORS) { onError?.(new Error( `Runtime token rejected ${consecutiveAuthErrors} times in a row — stopping. ` + `Run: commonly agent detach ${agentName}`, )); running = false; return; } } else { // TASK-025: everything that is not an auth rejection used to fall // through to `onError` with `nextPollDelayMs` still at `intervalMs`, // because that variable is only reassigned in the spawn-retry branch // above and a failed fetch never reaches it. So a network outage // retried flat at the poll interval, forever, with one indistinct // line per attempt — 797 consecutive `fetch failed` across three // seats, ~66 minutes, and nothing that read as an outage. // // Deliberately NOT a stop. `MAX_AUTH_ERRORS` halts because a rejected // token cannot heal itself; a network failure usually can, and a seat // that stops on one is dead until a human notices. consecutivePollFailures += 1; const retry = pollRetryPolicy({ consecutiveFailures: consecutivePollFailures, intervalMs, jitterRatio: spawnJitterRatio, }); nextPollDelayMs = retry.delayMs; if (retry.escalate) { log( `poll failed ${consecutivePollFailures}x in a row ` + `(${err?.message || 'unknown error'}) — backing off to ` + `${formatRetryDelay(retry.delayMs)}` + `${retry.atCeiling ? ', at the ceiling' : ''}`, ); } } onError?.(err); } // Notices landing during a turn share one three-second collection window // before the next inbox read, independent of the idle polling cadence. A // retry scheduled in the same tick keeps its own delay: the failure report // names it, and a sibling's completed turn must not shorten the backoff. if (running) setTimeoutImpl(tick, completedModelTurn && !retryScheduled ? 3000 : nextPollDelayMs); }; tick(); return { stop: () => { running = false; clearHeldRenewal(); }, }; }; // ── init: scaffold a webhook-SDK agent (ADR-006 §Scaffolding) ─────────────── const SUPPORTED_LANGUAGES = ['python']; // Resolve a repo-relative path from this module, regardless of where the CLI // is invoked from. agent.js lives at cli/src/commands/agent.js → repo root is // three levels up. The scaffolder copies the canonical SDK file from the repo // into the user's cwd; ADR-006 §SDK lives = "live-copy, not dependency". // // Removal condition: ADR-006 §Migration path Phase 4 — when the CLI is // published as `@commonlyai/cli` on npm, the example files won't sit at a // repo-relative path; we'll need to bundle them into the package at build // time and resolve via `import.meta.resolve` or a packaged-data lookup. // Until that phase, repo-relative is the simplest correct answer. const repoFile = (...parts) => pathResolve( dirname(fileURLToPath(import.meta.url)), '..', '..', '..', ...parts, ); const SDK_SOURCES = { python: { sdkSrc: () => repoFile('examples', 'sdk', 'python', 'commonly.py'), botSrc: () => repoFile('examples', 'hello-world-python', 'bot.py'), sdkOut: 'commonly.py', botOut: (name) => `${name}.py`, runHint: (name) => `COMMONLY_TOKEN=$(cat .commonly-env) python3 ${name}.py`, }, }; /** * Scaffold a webhook-SDK agent into `targetDir`. Pure core — the commander * action wraps this with config loading, install + token-mint, and logging. * * Refuses to clobber any of the three output files. Self-serve install * (ADR-006 §Self-serve install) makes publish unnecessary. */ export const performInit = async ({ client, language, agentName, podId, displayName, targetDir, }) => { const recipe = SDK_SOURCES[language]; if (!recipe) { throw new Error( `Unsupported language "${language}". Supported: ${SUPPORTED_LANGUAGES.join(', ')}`, ); } const sdkOutPath = join(targetDir, recipe.sdkOut); const botOutPath = join(targetDir, recipe.botOut(agentName)); const tokenOutPath = join(targetDir, '.commonly-env'); for (const f of [sdkOutPath, botOutPath, tokenOutPath]) { if (existsSync(f)) { throw new Error(`Refusing to overwrite existing file: ${f}`); } } if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); // Copy the SDK file verbatim; copy the bot template byte-for-byte (the // user renames their handler later — file name carries the agent name). writeFileSync(sdkOutPath, readFileSync(recipe.sdkSrc(), 'utf8'), 'utf8'); writeFileSync(botOutPath, readFileSync(recipe.botSrc(), 'utf8'), 'utf8'); // Self-serve install (ADR-006). No publish step; the backend synthesizes // an ephemeral registry row when no manifest exists for this name. const installResult = await client.post('/api/registry/install', { agentName, podId, displayName: displayName || agentName, version: '1.0.0', config: { runtime: { runtimeType: 'webhook', host: 'byo' } }, scopes: ['context:read', 'messages:write', 'memory:read', 'memory:write'], }); const installation = installResult.installation || installResult; const instanceId = installation.instanceId || 'default'; let runtimeToken = installResult.runtimeToken; if (!runtimeToken) { // ADR-005 §detach + reattach: pass force:true so the server clears the // hashed-but-unrecoverable prior token and mints fresh. const tokenData = await client.post( `/api/registry/pods/${podId}/agents/${agentName}/runtime-tokens`, { force: true }, ); runtimeToken = tokenData.token; } if (!runtimeToken) { throw new Error('Runtime token was not returned by install or tokens endpoint'); } // .commonly-env uses KEY=VALUE format so `source .commonly-env`, dotenv, // and standard env-loading tools work out of the box. Future keys (e.g. // COMMONLY_BASE_URL) just append. Mode 0600 (POSIX) since the file holds // a long-lived bearer token. writeFileSync(tokenOutPath, `COMMONLY_TOKEN=${runtimeToken}\n`, { encoding: 'utf8', mode: 0o600 }); return { installation, instanceId, runtimeToken, files: { sdk: sdkOutPath, bot: botOutPath, env: tokenOutPath, }, runHint: recipe.runHint(agentName), }; }; // ── detach: uninstall a local-CLI-wrapped agent (ADR-005) ─────────────────── /** * Uninstall the agent from its pod AND clean up local token + session files. * * The three pieces of state created by `attach` are removed in this order: * 1. Backend: AgentInstallation (DELETE /api/registry/agents/:name/pods/:podId * marks uninstalled, deletes AgentProfile, removes agent User from pod). * The runtime token tied to the User row is GC'd by the cleanup service * 7 days after all installs go inactive — we don't force-revoke here * because the token may legitimately still be in use for another pod. * 2. Local token file at ~/.commonly/tokens/.json * 3. Local session + handled-event stores at * ~/.commonly/sessions/{,.events}.json * * Idempotent: if the backend returns 404 (already uninstalled elsewhere), we * still clean up local files so the CLI state stays in sync with reality. * `--force` / `skipBackend:true` short-circuits to local-only cleanup for * the case where the backend has already been uninstalled via another path. */ export const performDetach = async ({ client, agentName, podId, instanceId = 'default', skipBackend = false, log = () => {}, }) => { if (!agentName || (!skipBackend && !podId)) { // A corrupted token file could leave us without a podId; without this // guard `encodeURIComponent(undefined)` produces a request against // `/pods/undefined` that the backend 404s, silently succeeding. throw new Error( 'performDetach requires agentName (and podId unless skipBackend=true)', ); } let backendResult = { skipped: true }; if (!skipBackend) { try { const body = await client.del(`/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}?instanceId=${encodeURIComponent(instanceId)}`); backendResult = { skipped: false, body }; log(`backend: uninstalled '${agentName}' from pod ${podId}`); } catch (err) { // 404 means "already gone" — continue to local cleanup. if (err.status === 404) { backendResult = { skipped: false, alreadyGone: true }; log(`backend: '${agentName}' already uninstalled from pod ${podId}`); } else { // Re-throw anything else (403 auth, 500, network) — caller decides. throw err; } } } deleteAgentToken(agentName); clearSessions(agentName); log(`local: removed token file + session store for '${agentName}'`); return { backend: backendResult, localCleaned: true }; }; /** * Every line a seat emits lands in one file, because the fleet is launched as * `nohup commonly agent run > 2>&1`. Until now none of those lines * carried a time. * * That is not a cosmetic gap. On 2026-08-18 a fleet-wide quota stall destroyed * 38 queued events (#993), and the seat log was the ONLY surviving trace — * the kernel rows were deleted, so nothing else recorded that those turns had * been attempted. The log records `(4 consecutive)` and `next probe in 2.0m` * and gives no way to place either on a clock: the investigation had to date * events by decoding ObjectId prefixes instead, because the file that named * them could not say when. * * ISO-8601 so stamps sort lexically, diff cleanly, and line up with the * kernel's own timestamps without conversion. */ const stamp = () => new Date().toISOString(); /** * Map `agent run`'s cascade flags onto resolveCascadeSettings' override keys. * * Exported because the mapping is where the flag path can go wrong invisibly: * an earlier version coerced with Number() here, so `--cascade-cap abc` * reached the resolver as NaN and the warning read `--cascade-cap='NaN'` — * naming a value the user never typed, on the one line whose whole job is to * tell them which input was bad. The values pass through RAW; the resolver is * the only thing that parses, for the same reason it is the only thing that * validates. */ export const cascadeOverridesFromOpts = (opts = {}) => ({ cascadeCap: opts.cascadeCap, cascadeAddressedGrace: opts.cascadeGrace, cascadeResetMs: opts.cascadeReset, }); export const registerAgent = (program) => { const agent = program.command('agent').description('Manage agents'); agent.addHelpText('after', ` Examples: # Wrap your local claude binary as a pod agent (ADR-005) $ commonly agent attach claude --pod --name my-claude $ commonly agent run my-claude $ commonly agent detach my-claude # Scaffold a custom Python agent (ADR-006) $ commonly agent init --language python --name research-bot --pod # List installed agents $ commonly agent list $ commonly agent config my-claude --adapter claude --model gpt-5.4 --effort high $ commonly agent config my-claude --model gpt-5.4 --effort high Docs: https://github.com/Team-Commonly/commonly/blob/main/docs/agents/LOCAL_CLI_WRAPPER.md https://github.com/Team-Commonly/commonly/blob/main/docs/agents/WEBHOOK_SDK.md `); // ── register ────────────────────────────────────────────────────────────── agent .command('register') .description('Register a webhook agent') .requiredOption('--name ', 'Agent name (e.g. my-agent)') .requiredOption('--pod ', 'Pod ID to install into') .requiredOption('--webhook ', 'Webhook URL (e.g. https://my-agent.example.com/cap)') .option('--secret ', 'Webhook signing secret (HMAC-SHA256)') .option('--display ', 'Display name shown in the pod') .option('--instance ', 'Target Commonly instance') .action(async (opts) => { const instanceUrl = resolveInstanceUrl(opts.instance); const token = getToken(opts.instance); if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } const client = createClient({ instance: instanceUrl, token }); try { // Publish the agent definition if it doesn't exist yet. await client.post('/api/registry/publish', { manifest: { name: opts.name, version: '1.0.0', description: 'A webhook-connected agent.', runtimeType: 'webhook', }, displayName: opts.display || opts.name, }).catch(() => { // Ignore publish errors — agent may already exist, install will proceed }); const result = await client.post('/api/registry/install', { agentName: opts.name, podId: opts.pod, displayName: opts.display || opts.name, version: '1.0.0', config: { runtime: { runtimeType: 'webhook', host: 'byo', webhookUrl: opts.webhook, ...(opts.secret ? { webhookSecret: opts.secret } : {}), }, }, scopes: ['context:read', 'messages:write', 'memory:read'], }); const installation = result.installation || result; const instId = installation.instanceId || 'default'; console.log(`Agent registered: ${installation.agentName} (${instId})`); console.log(`Pod: ${opts.pod}`); console.log(`Webhook: ${opts.webhook}`); // Fetch runtime token for agent connect let runtimeToken = result.runtimeToken; if (!runtimeToken) { try { const tokenData = await client.post( `/api/registry/pods/${opts.pod}/agents/${opts.name}/runtime-tokens`, { force: true }, ); runtimeToken = tokenData.token; } catch { // non-fatal — user can fetch manually } } if (runtimeToken) { console.log(`\nRuntime token: ${runtimeToken}`); console.log('Use this with: commonly agent connect --name --token '); } } catch (err) { console.error(`Registration failed: ${err.message}`); process.exit(1); } }); // ── connect ─────────────────────────────────────────────────────────────── agent .command('connect') .description('Start local dev loop — poll events and forward to localhost') .requiredOption('--name ', 'Agent name') .option('--port ', 'Local webhook server port', '3001') .option('--path ', 'Local webhook path', '/cap') .option('--secret ', 'Signing secret to verify forwarded events') .option('--token ', 'Agent runtime token (cm_agent_*) — use instead of user token for event polling') .option('--instance-id ', 'Instance ID (default: "default")', 'default') .option('--instance ', 'Target Commonly instance') .option('--interval ', 'Poll interval in ms', '5000') .action(async (opts) => { const instanceUrl = resolveInstanceUrl(opts.instance); const token = opts.token || getToken(opts.instance); if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } const port = parseInt(opts.port, 10); const localUrl = `http://localhost:${port}${opts.path}`; console.log(`Connecting ${opts.name} → ${instanceUrl}`); console.log(`Forwarding events to ${localUrl}`); console.log('Waiting for events... (Ctrl+C to stop)\n'); // Start local webhook server const { close } = await startWebhookServer({ port, path: opts.path, secret: opts.secret, onEvent: logOnlyHandler, onReady: (url) => console.log(`Local server ready: ${url}`), }); // Start poller — forwards each event to the local server const poller = startPoller({ instanceUrl, token, agentName: opts.name, instanceId: opts.instanceId, intervalMs: parseInt(opts.interval, 10), onEvent: async (event) => { const ts = new Date().toTimeString().slice(0, 8); process.stdout.write(`[${ts}] ${event.type.padEnd(18)}`); try { const result = await forwardToLocalWebhook(event, localUrl, opts.secret); console.log(`→ ${result.outcome}`); return result; } catch (err) { console.log(`→ error (${err.message})`); return { outcome: 'error', reason: err.message }; } }, onError: (err) => { console.error(`[poll error] ${err.message}`); }, }); // Graceful shutdown process.on('SIGINT', () => { console.log('\nStopping...'); poller.stop(); close(); process.exit(0); }); }); // ── attach (ADR-005, ADR-008) ───────────────────────────────────────────── agent .command('attach ') .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|opencode|qwen|dsh|…)') .requiredOption('--pod ', 'Pod ID to install into') .requiredOption('--name ', 'Agent name (e.g. my-claude)') .option('--memory-from ', 'Copy long-term memory from a hosted agent you own') .option('--memory-instance ', 'Hosted source instance for --memory-from', 'default') .option('--display ', 'Display name shown in the pod') .option('--env ', 'Path to environment.json (ADR-008 — sandbox/skills/MCP)') .option('--import-memory [path]', 'Import local memory (CLAUDE.md / MEMORY.md / ~/.claude project memory, or a given file/dir) into the agent after attach') .option('--yes', 'Skip the import confirmation prompt') .option('--wake-on-message', 'Wake this agent on every message in the pod, not only @mentions (ADR-018 ambient wake; default off). Change later with: commonly agent wake on|off') .option('--instance ', 'Target Commonly instance') .action(async (adapterName, opts) => { const instanceUrl = resolveInstanceUrl(opts.instance); const token = getToken(opts.instance); if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } const client = createClient({ instance: instanceUrl, token }); try { const envAbsPath = opts.env ? pathResolve(opts.env) : null; const { installation, instanceId, runtimeToken, detected, wrappedCli, displayName, environment, workspace, } = await performAttach({ client, adapterName, agentName: opts.name, podId: opts.pod, displayName: opts.display, envPath: envAbsPath, wakeOnMessage: Boolean(opts.wakeOnMessage), memoryFrom: opts.memoryFrom, memoryInstance: opts.memoryInstance, log: (line) => console.warn(`[attach] ${line}`), }); saveAgentToken(opts.name, { agentName: opts.name, displayName, instanceId, podId: opts.pod, instanceUrl, runtimeToken, adapter: wrappedCli, ...(environment ? { environment, workspacePath: workspace?.path || null, } : {}), }); console.log(`✓ ${wrappedCli} detected at ${detected.path} (${detected.version})`); console.log(`✓ Agent '${installation.agentName}' registered in pod ${opts.pod} (${instanceId})`); console.log(`✓ Runtime token saved to ${tokenFile(opts.name)}`); if (workspace) { console.log(`✓ Workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`); } if (opts.wakeOnMessage) { console.log('✓ Wake-on-message: ON (sees every pod message; answers only when addressed or genuinely useful)'); } // Retention plan Phase C: the agent arrives whole. Import failure is // a warning, never an attach failure — the attach already succeeded // and `commonly agent import-memory` can retry later. if (opts.importMemory) { try { const runtimeClient = createClient({ instance: instanceUrl, token: runtimeToken }); await runMemoryImport({ client: runtimeClient, explicitPath: typeof opts.importMemory === 'string' ? pathResolve(opts.importMemory) : null, yes: Boolean(opts.yes), }); } catch (err) { console.warn(`Memory import failed (agent is still attached): ${err.message}`); console.warn(`Retry with: commonly agent import-memory ${opts.name}`); } } // The mention handle is the instanceId (what the UI dropdown inserts), // not the registry agentName — say it here so users know how to ping it. const handle = instanceId && instanceId !== 'default' ? instanceId : installation.agentName; const binding = await checkAttachedAdapterBinding({ adapterName: wrappedCli, agentName: opts.name, podId: opts.pod, instanceId, instanceUrl, runtimeToken, }); printAttachRunGuidance({ agentName: opts.name, binding }); console.log(` Mention as: @${handle}`); } catch (err) { console.error(`Attach failed: ${err.message}`); process.exit(1); } }); // ── import-memory (retention plan Phase C) ──────────────────────────────── agent .command('import-memory ') .description("Import local memory (CLAUDE.md / MEMORY.md / ~/.claude project memory) into an attached agent's long_term memory") .option('--path ', 'Import a specific file or directory of .md files instead of auto-detecting') .option('--yes', 'Skip the confirmation prompt') .action(async (name, opts) => { const record = loadAgentToken(name); if (!record) { console.error(`No token for '${name}'. Attach it first: commonly agent attach --pod --name ${name}`); process.exit(1); } try { const client = createClient({ instance: record.instanceUrl, token: record.runtimeToken }); const result = await runMemoryImport({ client, explicitPath: opts.path ? pathResolve(opts.path) : null, yes: Boolean(opts.yes), }); if (!result.imported && result.reason === 'needs-confirmation') process.exit(1); } catch (err) { console.error(`Import failed: ${err.message}`); process.exit(1); } }); // ── import-skills (retention plan Phase C — metadata only) ──────────────── agent .command('import-skills ') .description("Register a local agent's skills (names + descriptions only) on its Commonly profile — content stays on your machine") .option('--dir ', 'Skills directory (default: /.claude/skills, then ~/.claude/skills)') .option('--yes', 'Skip the confirmation prompt') .option('--instance ', 'Target Commonly instance') .action(async (name, opts) => { const record = loadAgentToken(name); if (!record) { console.error(`No token for '${name}'. Attach it first: commonly agent attach --pod --name ${name}`); process.exit(1); } // Skill registration is a USER action (pod-membership-checked route), // unlike memory import which authenticates as the agent. const userToken = getToken(opts.instance); if (!userToken) { console.error('Not logged in. Run: commonly login'); process.exit(1); } try { const skills = detectSkills({ explicitDir: opts.dir ? pathResolve(opts.dir) : null }); if (!skills.length) { console.log('No skills found (looked for */SKILL.md under .claude/skills). Pass --dir to point at a skills directory.'); return; } console.log(`Found ${skills.length} skill${skills.length === 1 ? '' : 's'}:`); for (const s of skills) { console.log(` ${s.name}${s.description ? ` — ${s.description.slice(0, 80)}` : ''}`); } console.log('\nOnly names + descriptions are uploaded — skill content stays on this machine.'); if (!opts.yes) { if (!process.stdin.isTTY) { console.log('Not a terminal — re-run with --yes to confirm.'); process.exit(1); } const { createInterface } = await import('readline/promises'); const rl = createInterface({ input: process.stdin, output: process.stdout }); const answer = await rl.question(`Register on ${record.agentName}'s profile? [y/N] `); rl.close(); if (!/^y(es)?$/i.test(answer.trim())) { console.log('Cancelled.'); return; } } const client = createClient({ instance: record.instanceUrl, token: userToken }); const result = await importSkills(client, { skills, podId: record.podId, agentName: record.agentName, instanceId: record.instanceId || 'default', }); console.log(`✓ Registered ${result.imported} skill${result.imported === 1 ? '' : 's'} on ${record.agentName}'s profile.`); } catch (err) { console.error(`Skills import failed: ${err.message}`); process.exit(1); } }); // ── run (ADR-005) ───────────────────────────────────────────────────────── agent .command('run ') .description('Run the local-CLI wrapper loop for an attached agent') .option('--interval ', 'Poll interval in ms', '5000') .option('--adapter ', 'CLI to wrap on first-run bootstrap (claude|codex|pi|opencode|qwen); an existing token file bound to a different adapter stops the run') .option('--cascade-cap ', `Consecutive agent-triggered turns allowed per pod (env ${CASCADE_ENV_VARS.cap}, default ${CASCADE_DEFAULTS.cap})`) .option('--cascade-grace ', `Extra turns allowed when this seat was directly addressed; 0 disables the grace (env ${CASCADE_ENV_VARS.addressedGrace}, default ${CASCADE_DEFAULTS.addressedGrace})`) .option('--cascade-reset ', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`) .option('--refusal-handoff ', 'Agent to @mention when the model provider refuses a request\'s content; the request is acked as refused, never retried') .option('--allow ', 'On first run of an agent that works in its folder, also let it edit files and run commands (write) and/or fetch web pages (web), comma-separated. Commands reach the network.') .action(async (name, opts) => { let allow; try { allow = parseAllowGrants(opts.allow); } catch (err) { console.error(`${stamp()} [${name}] ${err.message}`); process.exit(1); } let record = loadAgentToken(name); let bootstrapped = false; if (!record) { // First run on this machine: the BYO connect page hands out env vars, // not a token file — bootstrap the record from them (#913). try { record = await bootstrapAgentRecordFromEnv({ name, adapterOverride: opts.adapter || null, log: (line) => console.log(`${stamp()} [${name}] ${line}`), allow, }); } catch (err) { console.error(`${stamp()} [${name}] ${err.message}`); // A Claude Code installed with npm is a .cmd shim Node cannot spawn, // so on Windows this is usually "installed but not runnable here". if (err.code === NO_AGENT_CLI_CODE && process.platform === 'win32') { console.error(windowsSandboxMessage('claude')); } process.exit(1); } if (!record) { console.error( `No token for '${name}'. Either export COMMONLY_API_URL + COMMONLY_AGENT_TOKEN` + ` (shown on the Connect your own agent page) and re-run, or:` + ` commonly agent attach --pod --name ${name}`, ); process.exit(1); } bootstrapped = true; } // Every turn of a seat this host cannot confine would fail, so say so // once, before a token file is written or a poll is made. const hostRefusal = hostSandboxRefusal({ environment: record.environment, adapterName: record.adapter }); if (hostRefusal) { console.error(`${stamp()} [${name}] ${hostRefusal}`); process.exit(1); } if (bootstrapped) { saveAgentToken(record.agentName, record); console.log(`${stamp()} [${name}] bootstrapped ${tokenFile(record.agentName)} from COMMONLY_AGENT_TOKEN (adapter: ${record.adapter})`); } else if (savedSeatLacksDefaultSandbox(record)) { console.warn( `${stamp()} [${name}] this agent's saved settings declare no sandbox, so it runs without one. ` + `To give it the default: commonly agent detach ${name} --force, then either run a fresh command ` + 'from the Connect page, or attach it again with an environment file that sets sandbox.trust: public.', ); } const allowRequest = checkRunAllowRequest({ record, requested: allow }); if (!allowRequest.ok) { console.error(`${stamp()} [${name}] ${allowRequest.message}`); process.exit(1); } const adapterRequest = checkRunAdapterRequest({ record, requestedAdapter: opts.adapter, tokenPath: tokenFile(name), }); if (!adapterRequest.ok) { console.error(`${stamp()} [${name}] ${adapterRequest.message}`); process.exit(1); } const adapter = getAdapter(record.adapter); if (!adapter) { console.error(`Unknown adapter '${record.adapter}' in token file. Known: ${listAdapterNames().join(', ')}`); process.exit(1); } if (adapterRequiresServerBinding(adapter.name)) { let identity; try { identity = await createClient({ instance: record.instanceUrl, token: record.runtimeToken, }).get('/api/agents/runtime/installations'); } catch (err) { console.error(`${stamp()} [${name}] could not read the server-declared adapter: ${err.message}`); process.exit(1); } const bindingRefusal = serverAdapterBindingRefusal({ adapterName: adapter.name, agentName: record.agentName, installations: identity?.installations, podId: record.podId, instanceId: record.instanceId, }); if (bindingRefusal) { console.error(`${stamp()} [${name}] ${bindingRefusal}`); process.exit(1); } } // THE line to stamp, not just one of them. It is the first line of every // seat log and the truncation boundary — the fleet is launched as // `nohup … > log 2>&1`, so this banner is written at boot and everything // before it is gone. Stamped, it dates the restart from the log itself. // Unstamped, it took `ps -o lstart` to establish when nine seats came // back on 2026-08-18, and that only worked because the processes were // still alive — after the next restart that route is gone too. await prepareMcpBeforeFirstPoll({ environment: record.environment, log: (line) => console.log(`${stamp()} [${name}] ${line}`), }); console.log(`${stamp()} [${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`); console.log(`${stamp()} [${name}] foreground mode; to background and keep it across logins, run: commonly daemon install`); const { stop } = performRun({ instanceUrl: record.instanceUrl, token: record.runtimeToken, adapter, agentName: record.agentName, displayName: record.displayName || record.agentName, instanceId: record.instanceId, podId: record.podId, environment: record.environment || null, workspacePath: record.workspacePath || null, intervalMs: parseInt(opts.interval, 10), ...cascadeOverridesFromOpts(opts), // TASK-102 part B: the operator's TTL ask, if any. Unset means the // server's published default, which is the only lifetime we can justify // without an operator saying otherwise. runtimeReportClient: createMachineReportClient({ instanceUrl: record.instanceUrl, machineId: record.machineId, agentName: record.agentName, instanceId: record.instanceId }), resolveOwnProviderKey: createOwnKeyResolver({ instanceUrl: record.instanceUrl, machineId: record.machineId, agentName: record.agentName, instanceId: record.instanceId }), reportRuntime: Boolean(record.machineId || process.env.COMMONLY_MANAGED_RUNNER === '1'), spawnCredentialTtlSeconds: resolveSpawnTtlSeconds(process.env), refusalHandoff: opts.refusalHandoff || null, log: (line) => console.log(`${stamp()} [${name}] ${line}`), // Both sinks are stamped, and both must be — but not for the reason // this comment gave until now, which its own PR falsified. // // A spawn failure is routed to `onError` when a caller provides one and // to `log` only as the fallback, so in `agent run` — which always passes // one — EVERY failure line comes out of the error channel and none out // of the log. Stamping only `log:` would leave the entire failure class // undated, which is the class these stamps exist for. // // The two sinks also carry genuinely different text elsewhere: the // no-prompt skip logs a terse line and sends a detailed diagnostic, so // one stamped and one bare would date half of that pair. // // (History, because the reasoning moved twice. This originally argued // from a DOUBLE emission — the retry path called both sinks with // identical text, so `grep -c 'session limit'` returned 8 for 4 failures // and a per-event count read as two deliveries. That was true when the // stamps landed and false eleven minutes later, when the same PR // collapsed the duplication. The conclusion survived the premise; the // sentence did not, and nothing in the diff pointed at it.) onError: (err) => console.error(`${stamp()} [${name}] ${err.message}`), }); // SIGTERM is the daemon's idle-boundary handoff signal. `stop()` stops // future polls but lets the in-flight turn finish and ack before the // event loop drains; exiting here would cut a model turn in half. // seat-stop also arms the supervised-group deadline before launchd's // SIGKILL, and kills any group still live when the process exits. const seatStop = createSeatStop({ name, stop }); const requestStop = (signal) => { console.log(`\n${stamp()} [${name}] stopping after the current turn...`); seatStop.requestStop(signal); }; process.on('SIGINT', requestStop); process.on('SIGTERM', requestStop); process.once('exit', seatStop.onExit); }); // ── detach (ADR-005) ────────────────────────────────────────────────────── agent .command('detach ') .description('Uninstall an attached agent from its pod and delete local state') .option('--force', 'Skip the backend uninstall call; only remove local files') .action(async (name, opts) => { const record = loadAgentToken(name); if (!record) { console.error( `No local state for '${name}'. If the agent is still installed on ` + `the backend, uninstall it via the Agent Hub UI or:\n` + ` curl -X DELETE '/api/registry/agents/${name}/pods/?instanceId=' \\\n` + ` -H "Authorization: Bearer "`, ); process.exit(1); } try { let client = null; if (!opts.force) { // The DELETE endpoint expects the user's JWT, not the agent runtime // token — matches the auth surface of attach/init. We use the saved // instanceUrl to find the right user token (getToken accepts URL). const userToken = getToken(record.instanceUrl); if (!userToken) { console.error( `No user login found for ${record.instanceUrl}. Either run ` + `'commonly login --instance ${record.instanceUrl}' first, or ` + `'commonly agent detach ${name} --force' to clean up local files only.`, ); process.exit(1); } client = createClient({ instance: record.instanceUrl, token: userToken }); } const result = await performDetach({ client, agentName: record.agentName, podId: record.podId, instanceId: record.instanceId || 'default', skipBackend: !!opts.force, log: (line) => console.warn(`[detach] ${line}`), }); if (opts.force) { console.log(`✓ Removed local state for '${name}' (backend NOT notified)`); } else if (result.backend?.alreadyGone) { console.log(`✓ '${name}' was already uninstalled from pod ${record.podId}; cleaned local state`); } else { console.log(`✓ Detached '${name}' from pod ${record.podId} and removed local state`); } } catch (err) { console.error(`Detach failed: ${err.message}`); console.error(`If the backend is unreachable, retry with --force to clean up local files.`); process.exit(1); } }); // ── init (ADR-006) ──────────────────────────────────────────────────────── agent .command('init') .description('Scaffold a webhook-SDK agent into the current directory') .requiredOption('--language ', `One of: ${SUPPORTED_LANGUAGES.join(', ')}`) .requiredOption('--name ', 'Agent name (e.g. research-bot)') .requiredOption('--pod ', 'Pod ID to install into') .option('--display ', 'Display name shown in the pod') .option('--dir ', 'Target directory (default: current dir)') .option('--instance ', 'Target Commonly instance') .action(async (opts) => { const instanceUrl = resolveInstanceUrl(opts.instance); const token = getToken(opts.instance); if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } const client = createClient({ instance: instanceUrl, token }); try { const result = await performInit({ client, language: opts.language, agentName: opts.name, podId: opts.pod, displayName: opts.display, targetDir: pathResolve(opts.dir || process.cwd()), }); console.log(`✓ Written: ${result.files.bot}`); console.log(`✓ Written: ${result.files.sdk}`); console.log(`✓ Registered '${opts.name}' in pod ${opts.pod} (${result.instanceId})`); console.log(`✓ Runtime token saved to ${result.files.env}`); console.log('\nNext:'); console.log(` 1. Edit ${opts.name}.py to handle events.`); console.log(` 2. Run: ${result.runHint}`); } catch (err) { console.error(`Init failed: ${err.message}`); process.exit(1); } }); // ── list ────────────────────────────────────────────────────────────────── agent .command('list') .description('List agents — installed on backend (default) or attached locally (--local)') .option('--local', 'List agents attached on this laptop (~/.commonly/tokens/)') .option('--pod ', 'Filter by pod (backend mode only)') .option('--instance ', 'Target Commonly instance (backend mode only)') .addHelpText('after', ` The two modes answer different questions: backend (default) — who is installed in pods I can see, on any driver --local — who have I attached on THIS laptop via 'agent attach' Use --local to find the name you'd pass to 'agent run' or 'agent detach'. `) .action(async (opts) => { if (opts.local) { const agents = listLocalAgents(); if (agents.length === 0) { console.log('No agents attached locally. Run: commonly agent attach --pod --name '); return; } const col = (s, w) => String(s ?? '').padEnd(w).slice(0, w); console.log(`${col('NAME', 20)} ${col('ADAPTER', 10)} ${col('POD', 26)} LAST TURN`); console.log('─'.repeat(80)); agents.forEach((a) => { const lastTurn = a.lastTurn ? new Date(a.lastTurn).toLocaleString() : 'never'; console.log(`${col(a.name, 20)} ${col(a.adapter, 10)} ${col(a.podId, 26)} ${lastTurn}`); }); return; } const token = getToken(opts.instance); if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } const client = createClient({ instance: resolveInstanceUrl(opts.instance), token }); try { const params = opts.pod ? { podId: opts.pod } : {}; const data = await client.get('/api/registry/admin/installations', params); const installations = data.installations || data || []; if (installations.length === 0) { console.log('No agents installed.'); return; } const col = (s, w) => String(s ?? '').padEnd(w).slice(0, w); console.log(`${col('NAME', 16)} ${col('INSTANCE', 10)} ${col('RUNTIME', 14)} ${col('HOST', 6)} ${col('STATUS', 10)} LAST SEEN`); console.log('─'.repeat(80)); installations.forEach((inst) => { const runtimeType = inst.config?.runtime?.runtimeType || inst.runtimeType || '?'; const host = inst.config?.runtime?.host || ''; const lastSeen = inst.usage?.lastUsedAt ? new Date(inst.usage.lastUsedAt).toLocaleString() : 'never'; console.log(`${col(inst.agentName, 16)} ${col(inst.instanceId, 10)} ${col(runtimeType, 14)} ${col(host, 6)} ${col(inst.status, 10)} ${lastSeen}`); }); } catch (err) { console.error(`Failed: ${err.message}`); process.exit(1); } }); // ── logs ────────────────────────────────────────────────────────────────── agent .command('logs ') .description('Stream recent events for an agent') .option('--instance-id ', 'Instance ID', 'default') .option('--follow', 'Keep polling for new events', false) .option('--instance ', 'Target Commonly instance') .action(async (name, opts) => { const token = getToken(opts.instance); if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } const client = createClient({ instance: resolveInstanceUrl(opts.instance), token }); const fetchAndPrint = async () => { const { events = [] } = await client.get('/api/agents/runtime/events', { agentName: name, instanceId: opts.instanceId, limit: 20, }); events.forEach((e) => { const ts = new Date(e.createdAt).toTimeString().slice(0, 8); const outcome = e.delivery?.outcome || e.status; console.log(`[${ts}] ${e.type.padEnd(18)} → ${outcome}`); }); return events; }; try { await fetchAndPrint(); if (opts.follow) { console.log('Following... (Ctrl+C to stop)'); setInterval(fetchAndPrint, 5000); } } catch (err) { console.error(`Failed: ${err.message}`); process.exit(1); } }); // ── config (existing-agent edit) ───────────────────────────────────────── agent .command('config ') .description('Update an attached agent\'s server-side runtime configuration') .option('--adapter ', 'Local runtime adapter (must be installed on this machine)') .option('--model ', 'Model identifier to use on the next daemon restart') .option('--effort ', 'Reasoning effort (low|medium|high|xhigh|max)') .option('--env ', 'Replace the ADR-008 environment spec with this JSON file') .option('--instance ', 'Target Commonly instance') .action(async (name, opts) => { const record = loadAgentToken(name); if (!record) { console.error(`No token file for '${name}' — is it attached on this machine? (commonly agent list --local)`); process.exit(1); } const instance = opts.instance || record.instanceUrl; const ownerToken = getToken(instance); const runtimeKey = !opts.adapter && !opts.env && !ownerToken && Boolean(record.runtimeToken); const token = runtimeKey ? record.runtimeToken : ownerToken; if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } try { const instanceUrl = resolveInstanceUrl(instance); if (runtimeKey && (!record.instanceUrl || new URL(instanceUrl).href.replace(/\/$/, '') !== new URL(record.instanceUrl).href.replace(/\/$/, ''))) { throw new Error('Changing --instance requires an owner login for that instance; run commonly login'); } const client = createClient({ instance: instanceUrl, token }); const result = await updateAgentConfiguration({ client, record, runtimeKey, adapter: opts.adapter, model: opts.model, effort: opts.effort, envPath: opts.env ? pathResolve(opts.env) : null, }); if (result.environment || result.adapter) { saveAgentToken(name, { ...record, ...(result.environment ? { environment: result.environment } : {}), ...(result.adapter ? { adapter: result.adapter } : {}), }); } console.log(`✓ Updated ${result.agentName} in pod ${result.podId} (${result.changed.join(', ')})`); console.log(' The daemon will apply the change on its next poll; a standalone agent run needs a restart.'); } catch (err) { console.error(`Failed: ${err.message}`); process.exit(1); } }); // ── wake (ADR-018 ambient wake toggle) ─────────────────────────────────── agent .command('wake ') .description('Turn wake-on-message on/off for an attached agent (off = @mentions and heartbeats only)') .option('--instance ', 'Target Commonly instance') .action(async (name, state, opts) => { const enabled = state === 'on'; if (!enabled && state !== 'off') { console.error(`Expected "on" or "off", got "${state}"`); process.exit(1); } const record = loadAgentToken(name); if (!record) { console.error(`No token file for '${name}' — is it attached on this machine? (commonly agent list --local)`); process.exit(1); } const instanceUrl = resolveInstanceUrl(opts.instance || record.instanceUrl); const token = getToken(opts.instance || record.instanceUrl); if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } const client = createClient({ instance: instanceUrl, token }); try { const result = await setWakeOnMessage({ client, record, enabled }); console.log(`✓ Wake-on-message ${result.enabled ? 'ON' : 'OFF'} for ${result.agentName} in pod ${result.podId}`); console.log(result.enabled ? ' The agent now sees every message in the pod; its runtime decides whether to answer (NO_REPLY otherwise). No restart needed.' : ' The agent now wakes only on @mentions, replies to it, and heartbeats. No restart needed.'); } catch (err) { console.error(`Failed: ${err.message}`); process.exit(1); } }); // ── heartbeat ───────────────────────────────────────────────────────────── agent .command('heartbeat ') .description('Manually trigger an agent heartbeat') .option('--instance ', 'Target Commonly instance') .action(async (name, opts) => { const token = getToken(opts.instance); if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); } const client = createClient({ instance: resolveInstanceUrl(opts.instance), token }); try { await client.post(`/api/registry/admin/agents/${name}/trigger-heartbeat`, {}); console.log(`Heartbeat triggered for ${name}`); } catch (err) { console.error(`Failed: ${err.message}`); process.exit(1); } }); // ── hooks-config (piece 7) ─────────────────────────────────────────────── agent .command('hooks-config ') .description('Write Claude Code HTTP hook entries for an attached agent') .option('--scope ', 'Write project or user settings', 'project') .option('--pod ', 'Pod to receive hook events (defaults to the attached pod)') .option('--file ', 'Explicit Claude settings file') .option('--timeout ', 'Hook request timeout in milliseconds', String(DEFAULT_HOOK_TIMEOUT_MS)) .action((name, opts) => { const record = loadAgentToken(name); if (!record?.runtimeToken) { console.error(`No runtime token found for '${name}'. Run commonly agent attach first.`); process.exit(1); } const podId = opts.pod || record.podId; if (!podId) { console.error('A pod is required (pass --pod or attach the agent to a pod).'); process.exit(1); } const timeoutMs = Number(opts.timeout); if (!Number.isFinite(timeoutMs) || timeoutMs < 250) { console.error('--timeout must be at least 250 milliseconds.'); process.exit(1); } const effectiveTimeoutMs = clampHookTimeoutMs(timeoutMs); const result = writeHooksConfig({ filePath: opts.file ? pathResolve(opts.file) : null, scope: opts.scope, agentName: name, timeoutMs: effectiveTimeoutMs, }); console.log(`✓ Claude hooks written to ${result.filePath}`); console.log(` Events: PreToolUse, PostToolUse, Stop, SubagentStop (timeout ${Math.ceil(effectiveTimeoutMs / 1000)}s)`); }); // ── hooks-forward (internal command emitted by hooks-config) ───────────── agent .command('hooks-forward ') .description('Forward one Claude Code hook event to Commonly') .option('--pod ', 'Pod to receive hook events (defaults to the attached pod)') .option('--timeout ', 'Hook request timeout in milliseconds', String(DEFAULT_HOOK_TIMEOUT_MS)) .action(async (name, opts) => { const record = loadAgentToken(name); const podId = opts.pod || record?.podId; const instanceUrl = record?.instanceUrl || process.env.COMMONLY_API_URL || resolveInstanceUrl(undefined); // Read the credential the way the runtime that spawned this hook carries // it: the launcher FILE first, the value variable second. Reading the bare // variable is what this did, and `resolveHookToken` therefore existed, // was tested, and never ran on a real hook — so on a seat whose MCP // declaration moved to the file, the token was absent, `forwardHookEvent` // returned `hook_unavailable`, and the hook silently stopped deciding // anything (the fail-open posture makes that a silence, not an error). const token = resolveHookToken({ env: process.env }); const chunks = []; if (!process.stdin.isTTY) { for await (const chunk of process.stdin) chunks.push(chunk); } const input = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '{}'; await forwardHookEvent({ endpoint: podId ? `${instanceUrl.replace(/\/$/, '')}/api/agents/runtime/pods/${encodeURIComponent(podId)}/hooks` : null, token, input, timeoutMs: Number(opts.timeout), }); }); }; // Re-exported for consumers that build their own wrapper command rather than // invoking commander (and for unit tests of the config contract). export { forwardHookEvent, writeHooksConfig } from '../lib/hooks-config.js';