@commonlyai/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Agent Environment resolver — ADR-008 Phase 1.
3
+ *
4
+ * An environment.yaml/.json file is the user-authored, driver-neutral spec for
5
+ * how an agent's runtime should be shaped: workspace path, sandbox mode, the
6
+ * Claude skills to mount, the MCP servers to expose. This module is the
7
+ * adapter-facing read-side: parse, validate, and realize the workspace +
8
+ * skill links on disk. Sandbox argv wrapping lives in `./sandbox/bwrap.js`
9
+ * because it is per-driver (Linux-only, bwrap-specific).
10
+ *
11
+ * Zero runtime deps: Node 20 has no built-in YAML, so we accept JSON only and
12
+ * surface a clear error on .yaml/.yml inputs (per task brief). When a future
13
+ * Node version exposes `node:yaml` natively, parseEnvironmentFile is the one
14
+ * place to add a `.yaml` branch — the rest of the module is parser-agnostic.
15
+ */
16
+
17
+ import { readFile, mkdir, symlink, lstat, readlink, cp } from 'fs/promises';
18
+ import { existsSync } from 'fs';
19
+ import { dirname, isAbsolute, join, resolve as pathResolve, basename } from 'path';
20
+ import { homedir } from 'os';
21
+
22
+ // ── Schema — keep the allow-list narrow; ADR-008 §invariants #1+#2 ──────────
23
+ //
24
+ // Deliberate omission: the env file's directory (needed to resolve `seed` and
25
+ // relative skill paths) is NOT stored on the returned spec. Leaking the user's
26
+ // absolute filesystem path into `config.environment` sent to the backend
27
+ // exposes `$HOME` layout for zero server-side benefit. Callers pass
28
+ // `envFileDir` as a separate argument to resolveWorkspace / linkSkills.
29
+
30
+ const ALLOWED_TOP_KEYS = new Set([
31
+ 'version', 'workspace', 'sandbox', 'skills', 'mcp',
32
+ ]);
33
+ const ALLOWED_SANDBOX_MODES = new Set(['none', 'bwrap', 'firejail', 'container', 'managed']);
34
+ const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']);
35
+
36
+ const expandHome = (p) => {
37
+ if (!p || typeof p !== 'string') return p;
38
+ if (p === '~') return homedir();
39
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
40
+ return p;
41
+ };
42
+
43
+ // ── parseEnvironmentFile ────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Read and parse the env file. Accepts JSON only in Phase 1 — .yaml/.yml
47
+ * inputs fail with a specific error pointing the user at JSON, so we never
48
+ * silently misread a YAML file as JSON.
49
+ */
50
+ export const parseEnvironmentFile = async (absolutePath) => {
51
+ if (!isAbsolute(absolutePath)) {
52
+ throw new Error(`parseEnvironmentFile requires an absolute path, got: ${absolutePath}`);
53
+ }
54
+ if (!existsSync(absolutePath)) {
55
+ throw new Error(`Environment file not found: ${absolutePath}`);
56
+ }
57
+
58
+ const lower = absolutePath.toLowerCase();
59
+ if (lower.endsWith('.yaml') || lower.endsWith('.yml')) {
60
+ throw new Error(
61
+ `YAML environment files are not supported in Phase 1 — Node 20 has no `
62
+ + `built-in YAML parser and we keep zero runtime deps. Convert ${absolutePath} `
63
+ + `to JSON (same shape) and pass that instead.`,
64
+ );
65
+ }
66
+
67
+ const raw = await readFile(absolutePath, 'utf8');
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(raw);
71
+ } catch (err) {
72
+ throw new Error(`Failed to parse environment file ${absolutePath}: ${err.message}`);
73
+ }
74
+
75
+ const validation = validateEnvironmentSpec(parsed);
76
+ if (!validation.ok) {
77
+ throw new Error(
78
+ `Invalid environment spec in ${absolutePath}:\n - ${validation.errors.join('\n - ')}`,
79
+ );
80
+ }
81
+
82
+ // Return the bare spec — no envFileDir wrapping, no underscore-prefixed
83
+ // annotations. The caller is responsible for tracking envFileDir separately
84
+ // (compute via `dirname(envPath)`) and passing it explicitly to
85
+ // resolveWorkspace / linkSkills when relative paths in the spec need to
86
+ // resolve. This keeps the spec safe to serialize and ship to the backend
87
+ // (`config.environment` on AgentInstallation) without leaking $HOME layout.
88
+ return parsed;
89
+ };
90
+
91
+ // ── validateEnvironmentSpec ─────────────────────────────────────────────────
92
+
93
+ export const validateEnvironmentSpec = (spec) => {
94
+ const errors = [];
95
+
96
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
97
+ return { ok: false, errors: ['env spec must be a JSON object'] };
98
+ }
99
+
100
+ for (const key of Object.keys(spec)) {
101
+ if (!ALLOWED_TOP_KEYS.has(key)) {
102
+ errors.push(`unknown top-level key: "${key}" (allowed: ${[...ALLOWED_TOP_KEYS].join(', ')})`);
103
+ }
104
+ }
105
+
106
+ if (spec.version !== undefined && spec.version !== 1) {
107
+ errors.push(`version must be 1, got ${JSON.stringify(spec.version)}`);
108
+ }
109
+
110
+ if (spec.workspace !== undefined) {
111
+ if (typeof spec.workspace !== 'object' || spec.workspace === null) {
112
+ errors.push('workspace must be an object');
113
+ } else {
114
+ if (spec.workspace.path !== undefined && typeof spec.workspace.path !== 'string') {
115
+ errors.push('workspace.path must be a string');
116
+ }
117
+ if (spec.workspace.seed !== undefined) {
118
+ if (!Array.isArray(spec.workspace.seed)
119
+ || !spec.workspace.seed.every((s) => typeof s === 'string')) {
120
+ errors.push('workspace.seed must be an array of strings');
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ if (spec.sandbox !== undefined) {
127
+ if (typeof spec.sandbox !== 'object' || spec.sandbox === null) {
128
+ errors.push('sandbox must be an object');
129
+ } else {
130
+ const { mode, network, filesystem } = spec.sandbox;
131
+ if (mode !== undefined && !ALLOWED_SANDBOX_MODES.has(mode)) {
132
+ errors.push(`sandbox.mode must be one of: ${[...ALLOWED_SANDBOX_MODES].join(', ')}`);
133
+ }
134
+ if (network !== undefined) {
135
+ if (typeof network !== 'object' || network === null) {
136
+ errors.push('sandbox.network must be an object');
137
+ } else {
138
+ if (network.policy !== undefined && !ALLOWED_NETWORK_POLICIES.has(network.policy)) {
139
+ errors.push(`sandbox.network.policy must be one of: ${[...ALLOWED_NETWORK_POLICIES].join(', ')}`);
140
+ }
141
+ if (network['allow-hosts'] !== undefined
142
+ && (!Array.isArray(network['allow-hosts'])
143
+ || !network['allow-hosts'].every((h) => typeof h === 'string'))) {
144
+ errors.push('sandbox.network.allow-hosts must be an array of strings');
145
+ }
146
+ }
147
+ }
148
+ if (filesystem !== undefined) {
149
+ if (typeof filesystem !== 'object' || filesystem === null) {
150
+ errors.push('sandbox.filesystem must be an object');
151
+ } else {
152
+ for (const f of ['read-outside', 'write-outside']) {
153
+ if (filesystem[f] !== undefined
154
+ && (!Array.isArray(filesystem[f])
155
+ || !filesystem[f].every((p) => typeof p === 'string'))) {
156
+ errors.push(`sandbox.filesystem.${f} must be an array of strings`);
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ if (spec.skills !== undefined) {
165
+ if (typeof spec.skills !== 'object' || spec.skills === null) {
166
+ errors.push('skills must be an object');
167
+ } else {
168
+ if (spec.skills.claude !== undefined
169
+ && (!Array.isArray(spec.skills.claude)
170
+ || !spec.skills.claude.every((p) => typeof p === 'string'))) {
171
+ errors.push('skills.claude must be an array of strings');
172
+ }
173
+ if (spec.skills.commonly !== undefined
174
+ && (!Array.isArray(spec.skills.commonly)
175
+ || !spec.skills.commonly.every((p) => typeof p === 'string'))) {
176
+ errors.push('skills.commonly must be an array of strings');
177
+ }
178
+ }
179
+ }
180
+
181
+ if (spec.mcp !== undefined) {
182
+ if (!Array.isArray(spec.mcp)) {
183
+ errors.push('mcp must be an array');
184
+ } else {
185
+ spec.mcp.forEach((server, i) => {
186
+ if (!server || typeof server !== 'object' || Array.isArray(server)) {
187
+ errors.push(`mcp[${i}] must be an object`);
188
+ return;
189
+ }
190
+ if (typeof server.name !== 'string' || !server.name) {
191
+ errors.push(`mcp[${i}].name is required and must be a non-empty string`);
192
+ }
193
+ if (server.transport !== undefined
194
+ && !['http', 'stdio', 'sse'].includes(server.transport)) {
195
+ errors.push(`mcp[${i}].transport must be one of: http, stdio, sse`);
196
+ }
197
+ });
198
+ }
199
+ }
200
+
201
+ return { ok: errors.length === 0, errors };
202
+ };
203
+
204
+ // ── resolveWorkspace ────────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Compute the workspace path and ensure it exists. On first creation, copy
208
+ * any `workspace.seed` paths in. Returns `{ path, created }` so the caller
209
+ * can log the outcome and (in the future) gate one-time setup.
210
+ *
211
+ * `envFileDir` is the directory of the env file (used to resolve relative
212
+ * seed paths). Required when `workspace.seed` contains relative entries;
213
+ * ignored otherwise.
214
+ */
215
+ export const resolveWorkspace = async (spec, agentName, envFileDir = null) => {
216
+ if (!agentName) throw new Error('resolveWorkspace requires agentName');
217
+ const declared = spec?.workspace?.path;
218
+ const path = expandHome(declared) || join(homedir(), '.commonly', 'workspaces', agentName);
219
+ const absPath = isAbsolute(path) ? path : pathResolve(path);
220
+
221
+ const created = !existsSync(absPath);
222
+ await mkdir(absPath, { recursive: true });
223
+
224
+ if (created && Array.isArray(spec?.workspace?.seed)) {
225
+ for (const entry of spec.workspace.seed) {
226
+ const src = isAbsolute(entry)
227
+ ? entry
228
+ : (envFileDir ? pathResolve(envFileDir, entry) : pathResolve(entry));
229
+ if (!existsSync(src)) {
230
+ throw new Error(`workspace.seed entry not found: ${src}`);
231
+ }
232
+ const dest = join(absPath, basename(src));
233
+ // eslint-disable-next-line no-await-in-loop
234
+ await cp(src, dest, { recursive: true, force: false, errorOnExist: false });
235
+ }
236
+ }
237
+
238
+ return { path: absPath, created };
239
+ };
240
+
241
+ // ── linkSkills ──────────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Symlink each `skills.claude[]` source path into `<workspacePath>/.claude/skills/`.
245
+ *
246
+ * Idempotent: if a symlink already points at the declared source, it is
247
+ * counted in `skipped` and left in place. If a DIFFERENT file or symlink
248
+ * occupies the slot, it is recorded in `conflicted` with a reason — never
249
+ * overwritten (a user-edited skill in the workspace must not be clobbered).
250
+ * Missing source paths are also reported in `conflicted` so the caller can
251
+ * surface them.
252
+ *
253
+ * `envFileDir` is the directory of the env file (used to resolve relative
254
+ * skill paths). Optional — when omitted, relative paths fall back to
255
+ * `workspacePath`. Pass it from `performAttach` for attach-time resolution
256
+ * matching the env file's location.
257
+ *
258
+ * Returns `{ linked, skipped, conflicted }`:
259
+ * - `linked`: string[] — newly created symlinks (absolute source paths)
260
+ * - `skipped`: string[] — already correctly linked (no-op)
261
+ * - `conflicted`: Array<{path, reason}> where reason ∈
262
+ * 'different-target' | 'not-symlink' | 'missing-source'
263
+ */
264
+ export const linkSkills = async (spec, workspacePath, envFileDir = null) => {
265
+ const sources = Array.isArray(spec?.skills?.claude) ? spec.skills.claude : [];
266
+ const linked = [];
267
+ const skipped = [];
268
+ const conflicted = [];
269
+ if (sources.length === 0) return { linked, skipped, conflicted };
270
+
271
+ const skillsDir = join(workspacePath, '.claude', 'skills');
272
+ await mkdir(skillsDir, { recursive: true });
273
+
274
+ for (const rawSource of sources) {
275
+ const source = expandHome(rawSource);
276
+ const absSource = isAbsolute(source)
277
+ ? source
278
+ : pathResolve(envFileDir || workspacePath, source);
279
+ if (!existsSync(absSource)) {
280
+ conflicted.push({ path: absSource, reason: 'missing-source' });
281
+ continue;
282
+ }
283
+ const linkPath = join(skillsDir, basename(absSource));
284
+
285
+ let existingTarget = null;
286
+ let slotIsNonSymlink = false;
287
+ try {
288
+ // eslint-disable-next-line no-await-in-loop
289
+ const stat = await lstat(linkPath);
290
+ if (stat.isSymbolicLink()) {
291
+ // eslint-disable-next-line no-await-in-loop
292
+ existingTarget = await readlink(linkPath);
293
+ } else {
294
+ slotIsNonSymlink = true;
295
+ }
296
+ } catch (err) {
297
+ // Only ENOENT means "slot is free, proceed." EACCES / EPERM / EIO are
298
+ // real failures that should surface — without this narrowing the
299
+ // subsequent symlink() call fails with a confusing follow-on error.
300
+ if (err.code !== 'ENOENT') throw err;
301
+ }
302
+
303
+ if (slotIsNonSymlink) {
304
+ conflicted.push({ path: absSource, reason: 'not-symlink' });
305
+ continue;
306
+ }
307
+
308
+ if (existingTarget) {
309
+ const resolvedExisting = isAbsolute(existingTarget)
310
+ ? existingTarget
311
+ : pathResolve(skillsDir, existingTarget);
312
+ if (resolvedExisting === absSource) {
313
+ skipped.push(absSource);
314
+ continue;
315
+ }
316
+ conflicted.push({ path: absSource, reason: 'different-target' });
317
+ continue;
318
+ }
319
+
320
+ // eslint-disable-next-line no-await-in-loop
321
+ await symlink(absSource, linkPath);
322
+ linked.push(absSource);
323
+ }
324
+
325
+ return { linked, skipped, conflicted };
326
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Memory bridge — ADR-005 §Memory bridge, ADR-003 Phase 2.
3
+ *
4
+ * Two thin CAP shims the run loop calls around every spawn cycle:
5
+ *
6
+ * readLongTerm(client) — GET /api/agents/runtime/memory,
7
+ * returns sections.long_term.content or ''
8
+ * syncBack(client, { summary }) — POST /api/agents/runtime/memory/sync
9
+ * with mode:'patch', sourceRuntime:'local-cli'.
10
+ * No-op when summary is falsy.
11
+ *
12
+ * Identity (agentName, instanceId) is derived server-side from the runtime
13
+ * token (agentRuntimeAuth), so neither helper needs to pass it. Keeping it
14
+ * server-derived is invariant-preserving: a bug in the wrapper can't write
15
+ * to the wrong agent's memory.
16
+ *
17
+ * ADR-003 invariant #9: the wrapper supplies `content` + `visibility` ONLY.
18
+ * `byteSize`, `updatedAt`, and `schemaVersion` are server-stamped; supplying
19
+ * them from the client is wasted bytes and the kernel discards them.
20
+ */
21
+
22
+ export const SOURCE_RUNTIME = 'local-cli';
23
+
24
+ export const readLongTerm = async (client, { onError } = {}) => {
25
+ try {
26
+ const body = await client.get('/api/agents/runtime/memory');
27
+ return body?.sections?.long_term?.content || '';
28
+ } catch (err) {
29
+ // A fresh agent has no memory row yet — treat as empty rather than fail
30
+ // the spawn. The kernel upserts on first write. For anything OTHER than
31
+ // a 404 (auth revoked, backend down, network out), surface via onError
32
+ // so the user sees "something's wrong with memory" instead of a silently
33
+ // context-less agent.
34
+ if (err?.status && err.status !== 404) {
35
+ onError?.(err);
36
+ }
37
+ return '';
38
+ }
39
+ };
40
+
41
+ export const syncBack = async (client, { summary } = {}) => {
42
+ if (!summary) return { skipped: true };
43
+ await client.post('/api/agents/runtime/memory/sync', {
44
+ mode: 'patch',
45
+ sourceRuntime: SOURCE_RUNTIME,
46
+ sections: {
47
+ long_term: {
48
+ content: summary,
49
+ visibility: 'private',
50
+ },
51
+ },
52
+ });
53
+ return { skipped: false };
54
+ };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Local memory import — Phase C of the retention plan ("your agent arrives
3
+ * whole"). Detects the memory a local agent already has on this machine
4
+ * (project CLAUDE.md / MEMORY.md, Claude Code's per-project auto-memory
5
+ * directory), composes it into one document, and promotes it into the
6
+ * kernel envelope's `long_term` section via POST /memory/sync.
7
+ *
8
+ * Design constraints:
9
+ * - Explicitly opt-in with preview (plan D3) — local memory files can
10
+ * contain private material; callers must confirm before anything is
11
+ * sent. This module only detects/composes/sends; the command layer
12
+ * owns the prompt.
13
+ * - APPEND, never clobber: `mode: 'patch'` replaces a section wholesale
14
+ * (mergePatchSections is section-level), so we read the existing
15
+ * long_term and append the import after it.
16
+ * - Server stamps byteSize/updatedAt/schemaVersion (ADR-003 invariant
17
+ * #9); we send content + visibility only.
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'fs';
21
+ import { homedir } from 'os';
22
+ import { join, basename } from 'path';
23
+
24
+ export const SOURCE_RUNTIME = 'import-local';
25
+
26
+ // Envelope sections are curated context, not dumps. The auto-clearer story
27
+ // on the agent side starts caring around 400KB of session; a quarter of
28
+ // that is a generous ceiling for a one-shot import.
29
+ export const MAX_IMPORT_BYTES = 256 * 1024;
30
+
31
+ // Claude Code keeps per-project auto-memory under
32
+ // ~/.claude/projects/<slug>/memory where <slug> is the project path with
33
+ // every '/' replaced by '-'.
34
+ export const claudeProjectMemoryDir = (cwd, home) => join(
35
+ home,
36
+ '.claude',
37
+ 'projects',
38
+ cwd.replaceAll('/', '-'),
39
+ 'memory',
40
+ );
41
+
42
+ const fileCandidate = (path, label) => {
43
+ if (!existsSync(path)) return null;
44
+ const stat = statSync(path);
45
+ if (!stat.isFile() || stat.size === 0) return null;
46
+ return { path, label, bytes: stat.size };
47
+ };
48
+
49
+ /**
50
+ * Find importable memory sources. An explicit path wins outright (file, or
51
+ * directory whose *.md files are all taken); otherwise auto-detect the
52
+ * well-known locations. Duplicates are collapsed by realpath — this repo,
53
+ * for instance, symlinks AGENTS.md → CLAUDE.md.
54
+ */
55
+ export const detectMemorySources = ({
56
+ cwd = process.cwd(),
57
+ home = homedir(),
58
+ explicitPath = null,
59
+ } = {}) => {
60
+ const candidates = [];
61
+
62
+ if (explicitPath) {
63
+ if (!existsSync(explicitPath)) {
64
+ throw new Error(`No such file or directory: ${explicitPath}`);
65
+ }
66
+ if (statSync(explicitPath).isDirectory()) {
67
+ for (const f of readdirSync(explicitPath).filter((n) => n.endsWith('.md')).sort()) {
68
+ const c = fileCandidate(join(explicitPath, f), `from ${basename(explicitPath)}/`);
69
+ if (c) candidates.push(c);
70
+ }
71
+ if (!candidates.length) {
72
+ throw new Error(`No .md files found in ${explicitPath}`);
73
+ }
74
+ } else {
75
+ const c = fileCandidate(explicitPath, 'explicit');
76
+ if (!c) throw new Error(`${explicitPath} is empty or not a regular file`);
77
+ candidates.push(c);
78
+ }
79
+ } else {
80
+ const projectInstructions = fileCandidate(join(cwd, 'CLAUDE.md'), 'project instructions');
81
+ if (projectInstructions) candidates.push(projectInstructions);
82
+ const agentsMd = fileCandidate(join(cwd, 'AGENTS.md'), 'project instructions');
83
+ if (agentsMd) candidates.push(agentsMd);
84
+ const projectMemory = fileCandidate(join(cwd, 'MEMORY.md'), 'project memory');
85
+ if (projectMemory) candidates.push(projectMemory);
86
+
87
+ const memDir = claudeProjectMemoryDir(cwd, home);
88
+ if (existsSync(memDir) && statSync(memDir).isDirectory()) {
89
+ for (const f of readdirSync(memDir).filter((n) => n.endsWith('.md')).sort()) {
90
+ const c = fileCandidate(join(memDir, f), 'auto-memory');
91
+ if (c) candidates.push(c);
92
+ }
93
+ }
94
+ }
95
+
96
+ // Collapse symlink duplicates (first mention wins its label).
97
+ const seen = new Set();
98
+ return candidates.filter((c) => {
99
+ let real;
100
+ try {
101
+ real = realpathSync(c.path);
102
+ } catch {
103
+ real = c.path;
104
+ }
105
+ if (seen.has(real)) return false;
106
+ seen.add(real);
107
+ return true;
108
+ });
109
+ };
110
+
111
+ /** Compose the sources into one markdown document with per-file provenance. */
112
+ export const composeImport = (sources) => {
113
+ if (!sources.length) throw new Error('Nothing to import');
114
+ const total = sources.reduce((n, s) => n + s.bytes, 0);
115
+ if (total > MAX_IMPORT_BYTES) {
116
+ throw new Error(
117
+ `Import is ${Math.round(total / 1024)}KB — over the ${MAX_IMPORT_BYTES / 1024}KB ceiling. `
118
+ + 'Pass an explicit --path to a smaller file, or trim the sources.',
119
+ );
120
+ }
121
+ const parts = sources.map((s) => {
122
+ const content = readFileSync(s.path, 'utf8').trim();
123
+ return `## Imported: ${basename(s.path)} (${s.label})\n\n${content}`;
124
+ });
125
+ return `# Imported local memory\n\n${parts.join('\n\n---\n\n')}`;
126
+ };
127
+
128
+ /**
129
+ * Promote the composed document into the agent's long_term section,
130
+ * appending after any existing content. `client` is an api.js client
131
+ * authenticated with the agent's runtime token.
132
+ */
133
+ export const importMemory = async (client, { sources }) => {
134
+ const imported = composeImport(sources);
135
+
136
+ let existing = '';
137
+ try {
138
+ const body = await client.get('/api/agents/runtime/memory');
139
+ existing = body?.sections?.long_term?.content || '';
140
+ } catch (err) {
141
+ // Fresh agents 404 — that's the common case. Anything else should stop
142
+ // the import: appending to memory we couldn't read risks clobbering it.
143
+ if (err?.status && err.status !== 404) throw err;
144
+ }
145
+
146
+ const content = existing ? `${existing.trimEnd()}\n\n${imported}` : imported;
147
+ await client.post('/api/agents/runtime/memory/sync', {
148
+ mode: 'patch',
149
+ sourceRuntime: SOURCE_RUNTIME,
150
+ sections: {
151
+ long_term: { content, visibility: 'private' },
152
+ },
153
+ });
154
+
155
+ return { files: sources.length, bytes: content.length, appended: Boolean(existing) };
156
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Event poller — polls GET /api/agents/runtime/events and forwards
3
+ * each event to a local handler (webhook server or callback).
4
+ *
5
+ * Used by `commonly agent connect` when running against any instance,
6
+ * since localhost webhooks can't receive inbound calls from remote GKE.
7
+ */
8
+
9
+ import { createClient } from './api.js';
10
+
11
+ export const startPoller = ({
12
+ instanceUrl,
13
+ token,
14
+ agentName,
15
+ instanceId = 'default',
16
+ intervalMs = 5000,
17
+ onEvent, // async (event) => { outcome, content? }
18
+ onError, // (err) => void
19
+ }) => {
20
+ const client = createClient({ instance: instanceUrl, token });
21
+ let running = true;
22
+ let consecutiveErrors = 0;
23
+ // Stop-after-N-auth-failures: without this, a revoked token leaves the
24
+ // poller hammering 401s forever at 60s backoff, invisible to the user.
25
+ // 3 is deliberate — 1 would churn on a token-rotation race during
26
+ // reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
27
+ let consecutiveAuthErrors = 0;
28
+ const MAX_AUTH_ERRORS = 3;
29
+
30
+ const poll = async () => {
31
+ if (!running) return;
32
+
33
+ try {
34
+ const { events = [] } = await client.get('/api/agents/runtime/events', {
35
+ agentName,
36
+ instanceId,
37
+ limit: 10,
38
+ });
39
+
40
+ for (const event of events) {
41
+ let result = { outcome: 'no_action' };
42
+ try {
43
+ result = (await onEvent(event)) || { outcome: 'no_action' };
44
+ } catch (handlerErr) {
45
+ result = { outcome: 'error', reason: handlerErr.message };
46
+ }
47
+
48
+ // Acknowledge the event
49
+ try {
50
+ await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
51
+ result,
52
+ });
53
+ } catch (ackErr) {
54
+ // Non-fatal — event will be retried
55
+ onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
56
+ }
57
+ }
58
+
59
+ consecutiveErrors = 0;
60
+ consecutiveAuthErrors = 0;
61
+ } catch (err) {
62
+ if (err?.status === 401 || err?.status === 403) {
63
+ consecutiveAuthErrors += 1;
64
+ if (consecutiveAuthErrors >= MAX_AUTH_ERRORS) {
65
+ onError?.(new Error(
66
+ `Runtime token rejected ${consecutiveAuthErrors} times in a row — stopping poller. `
67
+ + `The token is likely revoked.`,
68
+ ));
69
+ running = false;
70
+ return;
71
+ }
72
+ }
73
+ consecutiveErrors++;
74
+ onError?.(err);
75
+ // Back off on repeated errors (max 60s)
76
+ const backoff = Math.min(intervalMs * consecutiveErrors, 60_000);
77
+ await new Promise((r) => setTimeout(r, backoff));
78
+ }
79
+
80
+ if (running) setTimeout(poll, intervalMs);
81
+ };
82
+
83
+ // Start immediately
84
+ poll();
85
+
86
+ return {
87
+ stop: () => { running = false; },
88
+ };
89
+ };