@commonlyai/cli 0.1.88 → 0.1.92

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.88",
3
+ "version": "0.1.92",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -39,8 +39,18 @@ import { readLongTerm, syncBack } from '../lib/memory-bridge.js';
39
39
  import { pollRetryPolicy } from '../lib/poll-retry.js';
40
40
  import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
41
41
  import { detectSkills, importSkills } from '../lib/skills-import.js';
42
- import { normalizeSandboxTrust, parseEnvironmentFile, resolveWorkspace, validateEnvironmentSpec } from '../lib/environment.js';
43
- import { ADAPTERS_WITH_DEFAULT_MCP, defaultMcpServers } from '../lib/default-environment.js';
42
+ import {
43
+ environmentForServer,
44
+ normalizeSandboxTrust,
45
+ parseEnvironmentFile,
46
+ resolveWorkspace,
47
+ validateEnvironmentSpec,
48
+ } from '../lib/environment.js';
49
+ import {
50
+ ADAPTERS_WITH_DEFAULT_MCP,
51
+ ADAPTERS_WITH_GRANT_BROKER,
52
+ defaultMcpServers,
53
+ } from '../lib/default-environment.js';
44
54
  import { withholdGrantBroker } from '../lib/grant-broker-guard.js';
45
55
  import {
46
56
  FOCUS_FRAME_MAX_CODE_POINTS,
@@ -125,8 +135,114 @@ export const deleteAgentToken = (name) => {
125
135
  // podId) or is a local fact (which CLI binary to wrap). Returns a record ready
126
136
  // for saveAgentToken, or null when COMMONLY_AGENT_TOKEN isn't set (caller
127
137
  // falls back to the attach hint).
138
+ // OpenCode is opt-in: unlike the established wrappers it must not be selected
139
+ // just because its binary happens to be installed on the operator's machine.
128
140
  export const BOOTSTRAP_ADAPTER_DETECT_ORDER = ['claude', 'codex', 'pi'];
129
141
 
142
+ /** Return the adapter binding resolved for this exact installation by the server. */
143
+ export const runtimeAdapterForInstallation = ({ installations, podId, instanceId } = {}) => {
144
+ if (!Array.isArray(installations) || !podId) return null;
145
+ const expectedPodId = String(podId);
146
+ const expectedInstanceId = String(instanceId || 'default');
147
+ const installation = installations.find((row) => (
148
+ row?.type === 'installation'
149
+ && String(row.podId || '') === expectedPodId
150
+ && String(row.instanceId || 'default') === expectedInstanceId
151
+ ));
152
+ const adapter = String(installation?.runtimeAdapter || '').trim().toLowerCase();
153
+ return adapter || null;
154
+ };
155
+
156
+ /** Non-broker adapters need a server-resolved binding on env-token seats. */
157
+ export const adapterRequiresServerBinding = (adapterName) => {
158
+ const adapter = String(adapterName || '').trim().toLowerCase();
159
+ return Boolean(adapter) && adapter !== 'stub' && !ADAPTERS_WITH_GRANT_BROKER.has(adapter);
160
+ };
161
+
162
+ const serverRuntimeAdapterProjectionState = (installations) => {
163
+ if (!Array.isArray(installations)) return 'unknown';
164
+ const installationRows = installations.filter((row) => row?.type === 'installation');
165
+ if (installationRows.length === 0) return 'unknown';
166
+ return installationRows.some((row) => Object.prototype.hasOwnProperty.call(row, 'runtimeAdapter'))
167
+ ? 'present'
168
+ : 'legacy';
169
+ };
170
+
171
+ /** Return the refusal message unless this installation declares the same adapter. */
172
+ export const serverAdapterBindingRefusal = ({
173
+ adapterName,
174
+ agentName,
175
+ installations,
176
+ podId,
177
+ instanceId,
178
+ includeAttachGuidance = true,
179
+ } = {}) => {
180
+ const adapter = String(adapterName || '').trim().toLowerCase();
181
+ if (!adapterRequiresServerBinding(adapter)) return null;
182
+ const projectionState = serverRuntimeAdapterProjectionState(installations);
183
+ if (projectionState === 'unknown') {
184
+ return 'Could not verify the server adapter binding because the installations response is empty or missing. '
185
+ + 'Retry after the server returns this installation.';
186
+ }
187
+ if (projectionState === 'legacy') {
188
+ return `This Commonly server predates runtime adapter bindings; upgrade the server before running adapter '${adapter}'.`;
189
+ }
190
+ if (runtimeAdapterForInstallation({ installations, podId, instanceId }) === adapter) return null;
191
+ const refusal = `Adapter '${adapter}' is not declared for this Commonly installation.`;
192
+ if (!includeAttachGuidance) return refusal;
193
+ return `${refusal} A pod owner must sign in with commonly login, then run commonly agent attach ${adapter}`
194
+ + ` --pod ${podId || '<pod-id>'} --name ${agentName || '<agent-name>'}`
195
+ + ` [--env <environment.yaml>] before this seat can use adapter '${adapter}'.`;
196
+ };
197
+
198
+ /** Verify a newly attached adapter before telling the operator how to run it. */
199
+ export const checkAttachedAdapterBinding = async ({
200
+ adapterName,
201
+ agentName,
202
+ podId,
203
+ instanceId,
204
+ instanceUrl,
205
+ runtimeToken,
206
+ clientFactory = createClient,
207
+ } = {}) => {
208
+ if (!adapterRequiresServerBinding(adapterName)) return { canRun: true };
209
+
210
+ let identity;
211
+ try {
212
+ identity = await clientFactory({ instance: instanceUrl, token: runtimeToken })
213
+ .get('/api/agents/runtime/installations');
214
+ } catch (error) {
215
+ return {
216
+ canRun: false,
217
+ warning: `Could not verify the server adapter binding after attach: ${error.message}. `
218
+ + 'The installation was saved, but do not expect agent run to work until verification succeeds.',
219
+ };
220
+ }
221
+
222
+ const refusal = serverAdapterBindingRefusal({
223
+ adapterName,
224
+ agentName,
225
+ installations: identity?.installations,
226
+ podId,
227
+ instanceId,
228
+ includeAttachGuidance: false,
229
+ });
230
+ return refusal ? { canRun: false, warning: refusal } : { canRun: true };
231
+ };
232
+
233
+ export const printAttachRunGuidance = ({
234
+ agentName,
235
+ binding,
236
+ log = console.log,
237
+ warn = console.warn,
238
+ } = {}) => {
239
+ if (binding?.canRun) {
240
+ log(`\n Run with: commonly agent run ${agentName}`);
241
+ } else {
242
+ warn(`\n WARNING: ${binding?.warning || 'The server did not confirm this adapter binding.'}`);
243
+ }
244
+ };
245
+
130
246
  // ── `agent run --adapter` against an existing token file (#2098) ────────────
131
247
  // The flag picks the CLI once, at first-run bootstrap; after that the token
132
248
  // file's adapter is what runs. A different `--adapter` used to be dropped
@@ -213,6 +329,15 @@ export const bootstrapAgentRecordFromEnv = async ({
213
329
  log(`token belongs to '${agentName}', not '${name}' — using the token's identity`);
214
330
  }
215
331
 
332
+ const bindingRefusal = serverAdapterBindingRefusal({
333
+ adapterName,
334
+ agentName,
335
+ installations: installs,
336
+ podId: primary?.podId,
337
+ instanceId: primary?.instanceId,
338
+ });
339
+ if (bindingRefusal) throw new Error(bindingRefusal);
340
+
216
341
  return {
217
342
  agentName,
218
343
  displayName: identity?.displayName || primary?.displayName || agentName,
@@ -495,7 +620,11 @@ export const updateAgentConfiguration = async ({
495
620
  if (!environment && Object.keys(environmentRuntime).length) {
496
621
  environment = { ...environmentRuntime };
497
622
  }
498
- if (environment) config.environment = environment;
623
+ if (environment?.provider !== undefined
624
+ && String(runtime.adapter || record.adapter || '').trim().toLowerCase() !== 'opencode') {
625
+ throw new Error('environment.provider is supported only by the opencode adapter');
626
+ }
627
+ if (environment) config.environment = environmentForServer(environment);
499
628
  if (!Object.keys(config).length) {
500
629
  throw new Error('provide at least one of --adapter, --model, --effort, or --env');
501
630
  }
@@ -571,10 +700,10 @@ export const resolveAttachSandbox = ({
571
700
  // pure — a mode and an adapter name — so it belongs with the derivation, and
572
701
  // its witnesses live beside it.
573
702
  if ((mode === 'workspace' || mode === 'read-only')
574
- && (trust !== 'public' || !['codex', 'claude'].includes(adapterName))) {
703
+ && (trust !== 'public' || !['codex', 'claude', 'opencode'].includes(adapterName))) {
575
704
  throw new Error(
576
705
  `sandbox.mode=${mode} is currently implemented only for public `
577
- + 'codex or Claude adapters',
706
+ + 'Claude, Codex, or OpenCode adapters',
578
707
  );
579
708
  }
580
709
  return { mode, trust };
@@ -593,11 +722,12 @@ export const performAttach = async ({
593
722
  envPath = null,
594
723
  wakeOnMessage = false,
595
724
  log = () => {},
725
+ adapterRegistry = { getAdapter, listAdapterNames },
596
726
  }) => {
597
- const adapter = getAdapter(adapterName);
727
+ const adapter = adapterRegistry.getAdapter(adapterName);
598
728
  if (!adapter) {
599
729
  throw new Error(
600
- `Unknown adapter "${adapterName}". Known: ${listAdapterNames().join(', ')}`,
730
+ `Unknown adapter "${adapterName}". Known: ${adapterRegistry.listAdapterNames().join(', ')}`,
601
731
  );
602
732
  }
603
733
 
@@ -613,7 +743,13 @@ export const performAttach = async ({
613
743
  let workspace = null;
614
744
  if (envPath) {
615
745
  environment = await parseEnvironmentFile(envPath);
746
+ if (environment?.provider !== undefined && adapterName !== 'opencode') {
747
+ throw new Error('environment.provider is supported only by the opencode adapter');
748
+ }
616
749
  workspace = await resolveWorkspace(environment, agentName, dirname(envPath));
750
+ if (typeof adapter.validateEnvironment === 'function') {
751
+ await adapter.validateEnvironment(environment, workspace.path);
752
+ }
617
753
  log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
618
754
 
619
755
  const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName });
@@ -648,15 +784,16 @@ export const performAttach = async ({
648
784
  + `${seatbelt.error}. On Linux use sandbox.mode=bwrap.`,
649
785
  );
650
786
  }
787
+ const adapterLabel = adapterName === 'opencode' ? 'OpenCode' : 'Claude';
651
788
  log(
652
789
  `sandbox: public ${sandboxMode} via macOS Seatbelt `
653
- + '(deny-by-default host filesystem; Claude/MCP network retained)',
790
+ + `(deny-by-default host filesystem; ${adapterLabel}/MCP network retained)`,
654
791
  );
655
792
  }
656
793
  } else if (sandboxMode !== 'none' && sandboxMode !== undefined) {
657
794
  throw new Error(
658
795
  `sandbox.mode=${sandboxMode} is not yet implemented in the local-CLI driver. `
659
- + `Supported locally: none, bwrap, and public codex/Claude workspace/read-only.`,
796
+ + `Supported locally: none, bwrap, and public Codex/Claude/OpenCode workspace/read-only.`,
660
797
  );
661
798
  }
662
799
  } else {
@@ -719,9 +856,10 @@ export const performAttach = async ({
719
856
  config: {
720
857
  runtime: {
721
858
  runtimeType,
859
+ adapter: adapterName,
722
860
  host: 'byo',
723
861
  },
724
- ...(environment ? { environment } : {}),
862
+ ...(environment ? { environment: environmentForServer(environment) } : {}),
725
863
  // ADR-018: ambient wake is a per-install opt-in read by
726
864
  // agentMentionService (`config.wakeOnMessage.enabled === true`, default
727
865
  // OFF). Until now the only way to set it for a BYO seat was a DB write.
@@ -968,6 +1106,9 @@ export const performRun = ({
968
1106
  revokeOrphansImpl = revokeOrphanedSpawnCredentials,
969
1107
  sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
970
1108
  }) => {
1109
+ if (environment?.provider !== undefined && adapter?.name !== 'opencode') {
1110
+ throw new Error('environment.provider is supported only by the opencode adapter');
1111
+ }
971
1112
  const client = createClient({ instance: instanceUrl, token });
972
1113
 
973
1114
  // THE BOOT SWEEP IS THE SECOND NET, AND IT HAPPENS ONCE PER PROCESS. A seat
@@ -1594,7 +1735,35 @@ export const performRun = ({
1594
1735
  uploadName: `${agentName}-reply-${event._id}.md`,
1595
1736
  log: (line) => log(`[${event.type}] ${line}`),
1596
1737
  });
1597
- if (delivery.refused) {
1738
+ if (delivery.skipped) {
1739
+ // The server answered 200 and created nothing: a duplicate inside its
1740
+ // dedupe window, a silent body, a heartbeat it keeps. Nothing reached
1741
+ // the pod, so this is not a delivery and must not read as one in the
1742
+ // log — "posted 151 bytes as 1 message" over a skip is how a silent
1743
+ // seat looked like a talking one (hq-support, 2026-10-09 08:10Z). Ack
1744
+ // it like a refusal: re-posting the same text inside the window would
1745
+ // only be skipped again.
1746
+ deliveryRefusal = delivery;
1747
+ const windowMinutes = delivery.duplicate?.dedupeWindowMinutes;
1748
+ const detail = [
1749
+ windowMinutes ? `${windowMinutes} min window` : null,
1750
+ delivery.duplicate?.id ? `duplicate of ${delivery.duplicate.id}` : null,
1751
+ delivery.attemptedMessages > 1
1752
+ ? `after ${delivery.messages}/${delivery.attemptedMessages} messages`
1753
+ : null,
1754
+ ].filter(Boolean).join(', ');
1755
+ log(`[${event.type}] server skipped the post: ${delivery.reason}${detail ? ` (${detail})` : ''} — nothing reached the pod`);
1756
+ onError?.(Object.assign(
1757
+ new Error(`${event.type} server skipped the post: ${delivery.reason}`),
1758
+ {
1759
+ code: 'agent_delivery_skipped',
1760
+ reason: delivery.reason,
1761
+ eventId: event._id,
1762
+ postedMessages: delivery.messages,
1763
+ attemptedMessages: delivery.attemptedMessages,
1764
+ },
1765
+ ));
1766
+ } else if (delivery.refused) {
1598
1767
  // A run-cap refusal is a successful HTTP request but not a delivery.
1599
1768
  // Ack it so the kernel does not replay the same text (the server
1600
1769
  // guidance expressly says not to retry unchanged), while preserving
@@ -1640,6 +1809,8 @@ export const performRun = ({
1640
1809
  if (deliveryRefusal) {
1641
1810
  return {
1642
1811
  outcome: 'no_action',
1812
+ // Kernel class, from REFUSAL_REASONS: a skip is the server declining to
1813
+ // deliver, same class as a refusal; the specific reason rides below.
1643
1814
  refused: { reason: 'delivery-refused' },
1644
1815
  reason: deliveryRefusal.reason,
1645
1816
  details: {
@@ -2540,7 +2711,7 @@ Docs:
2540
2711
  // ── attach (ADR-005, ADR-008) ─────────────────────────────────────────────
2541
2712
  agent
2542
2713
  .command('attach <adapter>')
2543
- .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|…)')
2714
+ .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|opencode|…)')
2544
2715
  .requiredOption('--pod <podId>', 'Pod ID to install into')
2545
2716
  .requiredOption('--name <name>', 'Agent name (e.g. my-claude)')
2546
2717
  .option('--display <name>', 'Display name shown in the pod')
@@ -2616,7 +2787,15 @@ Docs:
2616
2787
  // The mention handle is the instanceId (what the UI dropdown inserts),
2617
2788
  // not the registry agentName — say it here so users know how to ping it.
2618
2789
  const handle = instanceId && instanceId !== 'default' ? instanceId : installation.agentName;
2619
- console.log(`\n Run with: commonly agent run ${opts.name}`);
2790
+ const binding = await checkAttachedAdapterBinding({
2791
+ adapterName: wrappedCli,
2792
+ agentName: opts.name,
2793
+ podId: opts.pod,
2794
+ instanceId,
2795
+ instanceUrl,
2796
+ runtimeToken,
2797
+ });
2798
+ printAttachRunGuidance({ agentName: opts.name, binding });
2620
2799
  console.log(` Mention as: @${handle}`);
2621
2800
  } catch (err) {
2622
2801
  console.error(`Attach failed: ${err.message}`);
@@ -2714,7 +2893,7 @@ Docs:
2714
2893
  .command('run <name>')
2715
2894
  .description('Run the local-CLI wrapper loop for an attached agent')
2716
2895
  .option('--interval <ms>', 'Poll interval in ms', '5000')
2717
- .option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex); an existing token file bound to a different adapter stops the run')
2896
+ .option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex|pi|opencode); an existing token file bound to a different adapter stops the run')
2718
2897
  .option('--cascade-cap <n>', `Consecutive agent-triggered turns allowed per pod (env ${CASCADE_ENV_VARS.cap}, default ${CASCADE_DEFAULTS.cap})`)
2719
2898
  .option('--cascade-grace <n>', `Extra turns allowed when this seat was directly addressed; 0 disables the grace (env ${CASCADE_ENV_VARS.addressedGrace}, default ${CASCADE_DEFAULTS.addressedGrace})`)
2720
2899
  .option('--cascade-reset <ms>', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`)
@@ -2760,6 +2939,30 @@ Docs:
2760
2939
  process.exit(1);
2761
2940
  }
2762
2941
 
2942
+ if (adapterRequiresServerBinding(adapter.name)) {
2943
+ let identity;
2944
+ try {
2945
+ identity = await createClient({
2946
+ instance: record.instanceUrl,
2947
+ token: record.runtimeToken,
2948
+ }).get('/api/agents/runtime/installations');
2949
+ } catch (err) {
2950
+ console.error(`${stamp()} [${name}] could not read the server-declared adapter: ${err.message}`);
2951
+ process.exit(1);
2952
+ }
2953
+ const bindingRefusal = serverAdapterBindingRefusal({
2954
+ adapterName: adapter.name,
2955
+ agentName: record.agentName,
2956
+ installations: identity?.installations,
2957
+ podId: record.podId,
2958
+ instanceId: record.instanceId,
2959
+ });
2960
+ if (bindingRefusal) {
2961
+ console.error(`${stamp()} [${name}] ${bindingRefusal}`);
2962
+ process.exit(1);
2963
+ }
2964
+ }
2965
+
2763
2966
  // THE line to stamp, not just one of them. It is the first line of every
2764
2967
  // seat log and the truncation boundary — the fleet is launched as
2765
2968
  // `nohup … > log 2>&1`, so this banner is written at boot and everything
@@ -136,8 +136,8 @@ export const unregisterDaemonMachine = async ({ client, record, remove = removeD
136
136
 
137
137
  // The adapter names a binary on THIS machine — the one fact the server cannot
138
138
  // know (same reasoning as `agent run`'s env bootstrap). A server-declared
139
- // preference is honored when that CLI is installed; otherwise probe the known
140
- // ones in order.
139
+ // preference is honored when that CLI is installed; otherwise probe only the
140
+ // established fallbacks. OpenCode must be named explicitly in runtime.adapter.
141
141
  export const resolveAdapterForRuntime = async (runtime, registry = { getAdapter }) => {
142
142
  const candidates = [runtime?.adapter, 'claude', 'codex'].filter(Boolean);
143
143
  for (const name of candidates) {
@@ -631,10 +631,11 @@ const prepareArgv = async (innerArgv, ctx) => {
631
631
  const wrapped = wrapArgvWithSeatbelt([claudeBin, ...innerArgv], {
632
632
  workspacePath: ctx.cwd,
633
633
  workspaceAccess: sandboxMode === 'read-only' ? 'read' : 'write',
634
- claudePath: claudeBin,
634
+ executablePath: claudeBin,
635
635
  statePath: ctx.publicClaudeState.statePath,
636
636
  mcpConfigDir: ctx.mcpConfigDir,
637
637
  executablePaths: [...mcpExecutables, ...commitHookExecutables, ...originalHookExecutables],
638
+ allowClaudeRuntimeAccess: true,
638
639
  });
639
640
  return {
640
641
  cmd: wrapped[0],
@@ -11,12 +11,14 @@ import stub from './stub.js';
11
11
  import claude from './claude.js';
12
12
  import codex from './codex.js';
13
13
  import pi from './pi.js';
14
+ import opencode from './opencode.js';
14
15
 
15
16
  const ADAPTERS = {
16
17
  [stub.name]: stub,
17
18
  [claude.name]: claude,
18
19
  [codex.name]: codex,
19
20
  [pi.name]: pi,
21
+ [opencode.name]: opencode,
20
22
  };
21
23
 
22
24
  export const listAdapterNames = () => Object.keys(ADAPTERS);