@commonlyai/cli 0.1.36 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'url';
18
18
 
19
19
  import { createClient } from '../lib/api.js';
20
20
  import { getToken, resolveInstanceUrl } from '../lib/config.js';
21
- import { startPoller } from '../lib/poller.js';
21
+ import { startPoller, terminalDeliveryAckError } from '../lib/poller.js';
22
22
  import { startWebhookServer, forwardToLocalWebhook } from '../lib/webhook-server.js';
23
23
  import { getAdapter, listAdapterNames } from '../lib/adapters/index.js';
24
24
  import {
@@ -32,7 +32,7 @@ import { readLongTerm, syncBack } from '../lib/memory-bridge.js';
32
32
  import { pollRetryPolicy } from '../lib/poll-retry.js';
33
33
  import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
34
34
  import { detectSkills, importSkills } from '../lib/skills-import.js';
35
- import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
35
+ import { parseEnvironmentFile, resolveWorkspace, validateEnvironmentSpec } from '../lib/environment.js';
36
36
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
37
37
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
38
38
  import {
@@ -376,6 +376,73 @@ export const setWakeOnMessage = async ({ client, record, enabled }) => {
376
376
  return { agentName: record.agentName, podId: record.podId, instanceId, enabled: Boolean(enabled) };
377
377
  };
378
378
 
379
+ /**
380
+ * Update the server-side configuration for an existing local agent. The
381
+ * registry PATCH route already fans this change out to accessible pod peers;
382
+ * keeping the CLI on that route avoids a second config API and lets the daemon
383
+ * pick up the change on its next work-list pass.
384
+ */
385
+ export const updateAgentConfiguration = async ({
386
+ client,
387
+ record,
388
+ model = null,
389
+ effort = null,
390
+ envPath = null,
391
+ parseEnv = parseEnvironmentFile,
392
+ }) => {
393
+ if (!record?.podId || !record?.agentName) {
394
+ throw new Error('token record is missing podId/agentName — re-attach the agent');
395
+ }
396
+ const instanceId = record.instanceId || 'default';
397
+ const runtime = {};
398
+ if (model !== null && model !== undefined) {
399
+ const normalizedModel = String(model);
400
+ const validation = validateEnvironmentSpec({ model: normalizedModel });
401
+ if (!validation.ok) throw new Error(validation.errors.join('; '));
402
+ runtime.model = normalizedModel;
403
+ }
404
+ if (effort !== null && effort !== undefined) {
405
+ const normalizedEffort = String(effort);
406
+ const validation = validateEnvironmentSpec({ effort: normalizedEffort });
407
+ if (!validation.ok) throw new Error(validation.errors.join('; '));
408
+ runtime.effort = normalizedEffort;
409
+ }
410
+ const config = {};
411
+ if (Object.keys(runtime).length) config.runtime = runtime;
412
+ let environment = null;
413
+ if (envPath) {
414
+ environment = await parseEnv(envPath);
415
+ } else if (record.environment && typeof record.environment === 'object' && !Array.isArray(record.environment)) {
416
+ environment = { ...record.environment };
417
+ }
418
+ // Keep the two historical control surfaces coherent. If a local token has
419
+ // an ADR-008 environment, a model/effort flag must update that declaration
420
+ // too; otherwise the daemon would correctly prefer the old explicit value
421
+ // over the new legacy runtime overlay.
422
+ if (environment && Object.keys(runtime).length) {
423
+ environment = { ...environment, ...runtime };
424
+ }
425
+ if (!environment && Object.keys(runtime).length) {
426
+ environment = { ...runtime };
427
+ }
428
+ if (environment) config.environment = environment;
429
+ if (!Object.keys(config).length) {
430
+ throw new Error('provide at least one of --model, --effort, or --env');
431
+ }
432
+
433
+ await client.patch(
434
+ `/api/registry/pods/${record.podId}/agents/${record.agentName}`,
435
+ { instanceId, config },
436
+ );
437
+ return {
438
+ agentName: record.agentName,
439
+ podId: record.podId,
440
+ instanceId,
441
+ changed: Object.keys(config),
442
+ ...(environment ? { environment } : {}),
443
+ };
444
+ };
445
+
379
446
  // ── attach: register a local-CLI-wrapped agent (ADR-005) ────────────────────
380
447
 
381
448
  /**
@@ -1586,6 +1653,12 @@ export const performRun = ({
1586
1653
  ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
1587
1654
  });
1588
1655
  } catch (ackErr) {
1656
+ const terminalError = terminalDeliveryAckError(ackErr, event._id, 'agent run');
1657
+ if (terminalError) {
1658
+ running = false;
1659
+ onError?.(terminalError);
1660
+ break;
1661
+ }
1589
1662
  onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
1590
1663
  }
1591
1664
  }
@@ -1670,6 +1743,12 @@ export const performRun = ({
1670
1743
  ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
1671
1744
  });
1672
1745
  } catch (ackErr) {
1746
+ const terminalError = terminalDeliveryAckError(ackErr, event._id, 'agent run');
1747
+ if (terminalError) {
1748
+ running = false;
1749
+ onError?.(terminalError);
1750
+ return;
1751
+ }
1673
1752
  onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
1674
1753
  }
1675
1754
  }
@@ -1946,6 +2025,7 @@ Examples:
1946
2025
 
1947
2026
  # List installed agents
1948
2027
  $ commonly agent list
2028
+ $ commonly agent config my-claude --model gpt-5.4 --effort high
1949
2029
 
1950
2030
  Docs:
1951
2031
  https://github.com/Team-Commonly/commonly/blob/main/docs/agents/LOCAL_CLI_WRAPPER.md
@@ -2357,11 +2437,15 @@ Docs:
2357
2437
  onError: (err) => console.error(`${stamp()} [${name}] ${err.message}`),
2358
2438
  });
2359
2439
 
2360
- process.on('SIGINT', () => {
2361
- console.log(`\n${stamp()} [${name}] stopping...`);
2440
+ // SIGTERM is the daemon's idle-boundary handoff signal. `stop()` stops
2441
+ // future polls but lets the in-flight turn finish and ack before the
2442
+ // event loop drains; exiting here would cut a model turn in half.
2443
+ const requestStop = () => {
2444
+ console.log(`\n${stamp()} [${name}] stopping after the current turn...`);
2362
2445
  stop();
2363
- process.exit(0);
2364
- });
2446
+ };
2447
+ process.on('SIGINT', requestStop);
2448
+ process.on('SIGTERM', requestStop);
2365
2449
  });
2366
2450
 
2367
2451
  // ── detach (ADR-005) ──────────────────────────────────────────────────────
@@ -2564,6 +2648,43 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
2564
2648
  }
2565
2649
  });
2566
2650
 
2651
+ // ── config (existing-agent edit) ─────────────────────────────────────────
2652
+ agent
2653
+ .command('config <name>')
2654
+ .description('Update an attached agent\'s server-side runtime configuration')
2655
+ .option('--model <id>', 'Model identifier to use on the next daemon restart')
2656
+ .option('--effort <level>', 'Reasoning effort (low|medium|high|xhigh|max)')
2657
+ .option('--env <path>', 'Replace the ADR-008 environment spec with this JSON file')
2658
+ .option('--instance <url>', 'Target Commonly instance')
2659
+ .action(async (name, opts) => {
2660
+ const record = loadAgentToken(name);
2661
+ if (!record) {
2662
+ console.error(`No token file for '${name}' — is it attached on this machine? (commonly agent list --local)`);
2663
+ process.exit(1);
2664
+ }
2665
+ const instance = opts.instance || record.instanceUrl;
2666
+ const token = getToken(instance);
2667
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
2668
+ const client = createClient({ instance: resolveInstanceUrl(instance), token });
2669
+ try {
2670
+ const result = await updateAgentConfiguration({
2671
+ client,
2672
+ record,
2673
+ model: opts.model,
2674
+ effort: opts.effort,
2675
+ envPath: opts.env ? pathResolve(opts.env) : null,
2676
+ });
2677
+ if (result.environment) {
2678
+ saveAgentToken(name, { ...record, environment: result.environment });
2679
+ }
2680
+ console.log(`✓ Updated ${result.agentName} in pod ${result.podId} (${result.changed.join(', ')})`);
2681
+ console.log(' The daemon will apply the change on its next poll; a standalone agent run needs a restart.');
2682
+ } catch (err) {
2683
+ console.error(`Failed: ${err.message}`);
2684
+ process.exit(1);
2685
+ }
2686
+ });
2687
+
2567
2688
  // ── wake (ADR-018 ambient wake toggle) ───────────────────────────────────
2568
2689
  agent
2569
2690
  .command('wake <name> <on|off>')
@@ -14,7 +14,7 @@ import {
14
14
  import { join } from 'path';
15
15
  import { createClient } from '../lib/api.js';
16
16
  import { getToken, resolveInstanceUrl } from '../lib/config.js';
17
- import { loadDaemonRecord, saveDaemonRecord } from '../lib/daemon-store.js';
17
+ import { loadDaemonRecord, removeDaemonRecord, saveDaemonRecord } from '../lib/daemon-store.js';
18
18
  import {
19
19
  createDaemonSupervisor,
20
20
  DEFAULT_HEARTBEAT_MS,
@@ -78,7 +78,7 @@ export const registerDaemonMachine = async ({
78
78
  await client.del(`/api/machines/${machine.id}`);
79
79
  } catch (revokeError) {
80
80
  throw new Error(
81
- `Could not store the daemon credential and could not revoke machine ${machine.name}: ${revokeError.message}`,
81
+ `Could not store the daemon credential and could not revoke machine ${machine.name} (${machine.id}): ${revokeError.message}`,
82
82
  );
83
83
  }
84
84
  throw new Error(`Could not store the daemon credential securely: ${error.message}. Machine registration was revoked.`);
@@ -96,6 +96,18 @@ export const getDaemonMachineStatus = async ({ client }) => {
96
96
  return response?.machine || null;
97
97
  };
98
98
 
99
+ // Revoke remotely before unlinking locally. A failed network call must leave
100
+ // the bearer record intact so the operator can retry rather than orphaning a
101
+ // year-long daemon credential they can no longer address from the CLI.
102
+ export const unregisterDaemonMachine = async ({ client, record, remove = removeDaemonRecord }) => {
103
+ try {
104
+ await client.del(`/api/machines/${record.machineDbId}`);
105
+ } catch (error) {
106
+ if (error?.status !== 404) throw error;
107
+ }
108
+ remove();
109
+ };
110
+
99
111
  // The adapter names a binary on THIS machine — the one fact the server cannot
100
112
  // know (same reasoning as `agent run`'s env bootstrap). A server-declared
101
113
  // preference is honored when that CLI is installed; otherwise probe the known
@@ -121,6 +133,7 @@ Examples:
121
133
  $ commonly daemon register --name "Sam's MacBook"
122
134
  $ commonly daemon heartbeat
123
135
  $ commonly daemon status
136
+ $ commonly daemon unregister
124
137
  `);
125
138
 
126
139
  daemon
@@ -160,6 +173,24 @@ Examples:
160
173
  }
161
174
  });
162
175
 
176
+ daemon
177
+ .command('unregister')
178
+ .description('Revoke this machine on the server and remove its local daemon credential')
179
+ .action(async () => {
180
+ try {
181
+ const record = requireDaemonRecord();
182
+ const client = createClient({
183
+ instance: record.instanceUrl,
184
+ token: requireUserToken(record.instanceUrl),
185
+ });
186
+ await unregisterDaemonMachine({ client, record });
187
+ console.log(`Unregistered ${record.machineName} and removed its local daemon credential.`);
188
+ } catch (error) {
189
+ console.error(`Daemon unregister failed: ${error.message}`);
190
+ process.exitCode = 1;
191
+ }
192
+ });
193
+
163
194
  daemon
164
195
  .command('heartbeat')
165
196
  .description('Send a machine liveness heartbeat using the stored daemon credential')
@@ -267,6 +267,8 @@ const buildArgs = ({
267
267
  outputFile,
268
268
  mcpFlags = [],
269
269
  publicSandboxMode = null,
270
+ model = null,
271
+ effort = null,
270
272
  }) => {
271
273
  const publicSandbox = publicSandboxMode !== null;
272
274
  // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
@@ -293,6 +295,8 @@ const buildArgs = ({
293
295
  '--json',
294
296
  '--skip-git-repo-check',
295
297
  ...executionPolicy,
298
+ ...(model ? ['--model', String(model)] : []),
299
+ ...(effort ? ['-c', 'model_reasoning_effort=' + toml(effort)] : []),
296
300
  ...mcpFlags,
297
301
  '-o',
298
302
  outputFile,
@@ -444,6 +448,8 @@ export default {
444
448
  outputFile,
445
449
  mcpFlags: mcp.flags,
446
450
  publicSandboxMode,
451
+ model: ctx.environment?.model,
452
+ effort: ctx.environment?.effort,
447
453
  });
448
454
  const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
449
455
  if (publicSandboxMode !== null) {
@@ -73,3 +73,13 @@ export const saveDaemonRecord = (record) => {
73
73
  throw error;
74
74
  }
75
75
  };
76
+
77
+ // Call this only after the owner has revoked the matching server-side machine.
78
+ // Local deletion first would strand a live credential with no CLI path to
79
+ // revoke it, which is exactly the failure this command exists to prevent.
80
+ export const removeDaemonRecord = () => {
81
+ const path = daemonRecordPath();
82
+ if (!existsSync(path)) return false;
83
+ unlinkSync(path);
84
+ return true;
85
+ };
@@ -1,3 +1,7 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, resolve as pathResolve } from 'node:path';
4
+
1
5
  /**
2
6
  * ADR-026 Phase 2, slice 2: the resident supervision loop behind
3
7
  * `commonly daemon run`.
@@ -22,6 +26,15 @@ export const DEFAULT_HEARTBEAT_MS = 30_000;
22
26
  export const BACKOFF_BASE_MS = 5_000;
23
27
  export const BACKOFF_MAX_MS = 60_000;
24
28
 
29
+ const workspacePathFor = (environment) => {
30
+ const declared = environment?.workspace?.path;
31
+ if (typeof declared !== 'string' || !declared.trim()) return null;
32
+ const expanded = declared === '~'
33
+ ? homedir()
34
+ : (declared.startsWith('~/') ? `${homedir()}/${declared.slice(2)}` : declared);
35
+ return isAbsolute(expanded) ? expanded : pathResolve(expanded);
36
+ };
37
+
25
38
  export const backoffMs = (restarts) => Math.min(
26
39
  BACKOFF_MAX_MS,
27
40
  BACKOFF_BASE_MS * 2 ** Math.max(0, Math.min(restarts, 10)),
@@ -87,11 +100,24 @@ export const createDaemonSupervisor = ({
87
100
  }
88
101
  };
89
102
 
90
- // The server-declared runtime config carries the owner's model choice; the
91
- // adapter reads it from the token record's environment (claude: --model).
92
- const environmentFor = (row) => (
93
- row.runtime?.model ? { model: String(row.runtime.model) } : null
94
- );
103
+ // Preserve the complete ADR-008 environment when the daemon receives it.
104
+ // Older installs only expose runtime.model/effort; those fields are a
105
+ // compatibility overlay and must merge into an existing local environment
106
+ // rather than erasing its workspace, skills, or MCP declarations.
107
+ const environmentFor = (row) => {
108
+ const declared = row.environment && typeof row.environment === 'object'
109
+ && !Array.isArray(row.environment) ? { ...row.environment } : null;
110
+ const runtime = row.runtime && typeof row.runtime === 'object' ? row.runtime : {};
111
+ if (declared) {
112
+ if (runtime.model && declared.model === undefined) declared.model = String(runtime.model);
113
+ if (runtime.effort && declared.effort === undefined) declared.effort = String(runtime.effort);
114
+ return { value: declared, declared: true };
115
+ }
116
+ const fallback = {};
117
+ if (runtime.model) fallback.model = String(runtime.model);
118
+ if (runtime.effort) fallback.effort = String(runtime.effort);
119
+ return Object.keys(fallback).length ? { value: fallback, declared: false } : null;
120
+ };
95
121
 
96
122
  // Ensure ~/.commonly/tokens/<name>.json exists so `agent run` can boot.
97
123
  // The mint refuses to clobber an existing token (409 token_exists); the
@@ -107,10 +133,22 @@ export const createDaemonSupervisor = ({
107
133
  // once at boot). A row with NO declared model leaves the record alone —
108
134
  // never strip an operator's hand-set environment.
109
135
  const wanted = environmentFor(row);
110
- if (wanted && existing.environment?.model !== wanted.model) {
111
- saveToken(row.agentName, { ...existing, environment: { ...(existing.environment || {}), ...wanted } });
112
- log(`[${row.agentName}] model changed to ${wanted.model} — restarting the seat to load it`);
113
- return 'changed';
136
+ if (wanted) {
137
+ const nextEnvironment = wanted.declared
138
+ ? wanted.value
139
+ : { ...(existing.environment || {}), ...wanted.value };
140
+ const workspacePath = workspacePathFor(nextEnvironment);
141
+ const nextRecord = {
142
+ ...existing,
143
+ environment: nextEnvironment,
144
+ ...(workspacePath ? { workspacePath } : {}),
145
+ };
146
+ if (!isDeepStrictEqual(existing.environment || null, nextEnvironment)
147
+ || (workspacePath && existing.workspacePath !== workspacePath)) {
148
+ saveToken(row.agentName, nextRecord);
149
+ log('runtime config changed — restarting the seat to load it');
150
+ return 'changed';
151
+ }
114
152
  }
115
153
  return 'ready';
116
154
  }
@@ -149,9 +187,13 @@ export const createDaemonSupervisor = ({
149
187
  instanceUrl: record.instanceUrl,
150
188
  podId: row.podIds?.[0] || null,
151
189
  adapter,
152
- ...(environment ? { environment } : {}),
190
+ ...(environment ? { environment: environment.value } : {}),
191
+ ...(environment?.value ? (() => {
192
+ const workspacePath = workspacePathFor(environment.value);
193
+ return workspacePath ? { workspacePath } : {};
194
+ })() : {}),
153
195
  });
154
- log(`[${row.agentName}] provisioned runtime token (adapter: ${adapter}${environment ? `, model: ${environment.model}` : ''})`);
196
+ log(`[${row.agentName}] provisioned runtime token (adapter: ${adapter}${environment?.value?.model ? `, model: ${environment.value.model}` : ''})`);
155
197
  return 'ready';
156
198
  };
157
199
 
package/src/lib/poller.js CHANGED
@@ -8,6 +8,26 @@
8
8
 
9
9
  import { createClient } from './api.js';
10
10
 
11
+ // Delivery failures that must stop a consumer rather than fall through to the
12
+ // normal at-least-once retry path. A stale delivery belongs to a replacement
13
+ // runner; a missing required nonce means this runner is misconfigured. Neither
14
+ // can become healthy by immediately fetching and retrying more work.
15
+ export const terminalDeliveryAckError = (ackErr, eventId, runner = 'poller') => {
16
+ if (ackErr?.status === 409 && ackErr?.body?.code === 'stale_delivery') {
17
+ return Object.assign(
18
+ new Error(`Delivery ${eventId} was superseded — stopping ${runner}.`),
19
+ { code: 'stale_delivery' },
20
+ );
21
+ }
22
+ if (ackErr?.status === 400 && ackErr?.body?.code === 'delivery_id_required') {
23
+ return Object.assign(
24
+ new Error(`Delivery ${eventId} requires deliveryId — stopping ${runner}; update its configuration.`),
25
+ { code: 'delivery_id_required' },
26
+ );
27
+ }
28
+ return null;
29
+ };
30
+
11
31
  export const startPoller = ({
12
32
  instanceUrl,
13
33
  token,
@@ -53,6 +73,12 @@ export const startPoller = ({
53
73
  ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
54
74
  });
55
75
  } catch (ackErr) {
76
+ const terminalError = terminalDeliveryAckError(ackErr, event._id);
77
+ if (terminalError) {
78
+ running = false;
79
+ onError?.(terminalError);
80
+ return;
81
+ }
56
82
  // Non-fatal — event will be retried
57
83
  onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
58
84
  }