@commonlyai/cli 0.1.166 → 0.1.169

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.166",
3
+ "version": "0.1.169",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1,3 +1,6 @@
1
+ import { createOwnKeyResolver, createMachineReportClient } from '../lib/own-key-launch.js';
2
+ import { WALKED_HARNESSES } from '../lib/runtime-inventory.js';
3
+ import { createRuntimeReporter } from '../lib/runtime-observation.js';
1
4
  /**
2
5
  * commonly agent <subcommand>
3
6
  *
@@ -1262,8 +1265,12 @@ export const performRun = ({
1262
1265
  sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
1263
1266
  // The agent to @mention when the model provider refuses a request's content
1264
1267
  // (lib/content-refusal.js). Null: the refusal notice names no one.
1268
+ reportRuntime = false,
1269
+ resolveOwnProviderKey = null,
1270
+ runtimeReportClient = null,
1265
1271
  refusalHandoff = null,
1266
1272
  }) => {
1273
+ reportRuntime = reportRuntime && WALKED_HARNESSES.includes(adapter?.name);
1267
1274
  if (environment?.provider !== undefined && !adapter?.supportsProvider) {
1268
1275
  throw new Error('environment.provider requires a provider-capable adapter');
1269
1276
  }
@@ -1271,6 +1278,11 @@ export const performRun = ({
1271
1278
  const effectiveTurnTimeoutMs = Number.isFinite(requestedTurnTimeout) && requestedTurnTimeout > 0
1272
1279
  ? Math.min(requestedTurnTimeout, 2_147_483_647) : 15 * 60 * 1000;
1273
1280
  const client = createClient({ instance: instanceUrl, token });
1281
+ const runtimeReporter = createRuntimeReporter({
1282
+ client: runtimeReportClient || { post: async () => {} },
1283
+ harness: adapter.name, detect: () => adapter.detect(),
1284
+ });
1285
+ const runtimeConnected = reportRuntime ? runtimeReporter.connect() : Promise.resolve();
1274
1286
  const missingModelNotices = new Set();
1275
1287
  const handoffAgent = sanitizeHandoffAgent(refusalHandoff);
1276
1288
  if (refusalHandoff && !handoffAgent) {
@@ -1321,12 +1333,18 @@ export const performRun = ({
1321
1333
  // the adapter spawns with, and the stored record keeps its broker, so the
1322
1334
  // daemon stays the layer that judges a host it knows. Applied once for the
1323
1335
  // run rather than per turn: `agent run` reads its record once at boot.
1324
- const seatEnvironment = withholdGrantBroker(environment, adapter?.name, {
1336
+ const requestedSeatEnvironment = withholdGrantBroker(environment, adapter?.name, {
1325
1337
  instanceUrl,
1326
1338
  onRefuse: (refusal, names) => log(
1327
1339
  `${refusal.code} (${refusal.reason}) — withholding ${names.join(', ')}: ${refusal.detail}`,
1328
1340
  ),
1329
1341
  });
1342
+ // Without native effort inventory a machine runner cannot promise that a
1343
+ // stored effort is supported. Preserve the record and use harness default.
1344
+ // Standalone CLI behavior remains under the operator's control.
1345
+ const runtimeInventoryPromise = reportRuntime ? Promise.resolve(adapter.inventory?.()).then((value) => value || { models: null, efforts: [] })
1346
+ .catch(() => ({ models: null, efforts: [] })) : Promise.resolve(null);
1347
+ let seatEnvironment = requestedSeatEnvironment;
1330
1348
  // OpenCode retains tool-call IDs in its resumed history. A Gemini proxy
1331
1349
  // marks those IDs with signature references, so changing providers must
1332
1350
  // start a fresh session before another upstream can receive them.
@@ -1384,6 +1402,10 @@ export const performRun = ({
1384
1402
  const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10));
1385
1403
 
1386
1404
  const processEvent = async (event) => {
1405
+ const inventory = await runtimeInventoryPromise;
1406
+ if (reportRuntime && requestedSeatEnvironment && !inventory?.efforts.includes(requestedSeatEnvironment.effort)) {
1407
+ seatEnvironment = { ...requestedSeatEnvironment, effort: undefined };
1408
+ }
1387
1409
  const eventPodId = event.podId || podId;
1388
1410
  const prompt = extractPrompt(event);
1389
1411
  if (!prompt || !eventPodId) {
@@ -1785,6 +1807,7 @@ export const performRun = ({
1785
1807
  ? { mounted: serverSkills.mounted, conflicted: serverSkills.conflicted, ...(skillReadUnavailable ? { status: 'unavailable' } : {}) }
1786
1808
  : null;
1787
1809
  let spawnCompleted = false;
1810
+ let observedSpawn = false;
1788
1811
  let commitAttribution = null;
1789
1812
  try {
1790
1813
  // The local CLI is a wrapper for a Commonly seat, so its Git commits
@@ -1802,7 +1825,24 @@ export const performRun = ({
1802
1825
  effort: seatEnvironment?.effort,
1803
1826
  });
1804
1827
  }
1828
+ await runtimeConnected;
1829
+ let providerKey;
1830
+ if (reportRuntime && resolveOwnProviderKey) {
1831
+ try {
1832
+ providerKey = await resolveOwnProviderKey({ opened, harness: adapter.name,
1833
+ provider: seatEnvironment?.provider });
1834
+ } catch (error) {
1835
+ if (!error.ownKeySpawnRefused) throw error;
1836
+ return { outcome: 'no_action', refused: { reason: 'delivery-refused' },
1837
+ reason: 'own-key-requires-scoped-spawn' };
1838
+ }
1839
+ }
1805
1840
  result = await adapter.spawn(frameDecisionForkRule(promptForSpawn), {
1841
+ onSpawn: reportRuntime ? (controls) => {
1842
+ observedSpawn = true;
1843
+ return runtimeReporter.spawn(controls);
1844
+ } : undefined,
1845
+ providerKey,
1806
1846
  timeoutMs: effectiveTurnTimeoutMs,
1807
1847
  sessionId,
1808
1848
  cwd: agentCwd,
@@ -1831,6 +1871,7 @@ export const performRun = ({
1831
1871
  spawnCompleted = true;
1832
1872
  } finally {
1833
1873
  try {
1874
+ if (reportRuntime) await runtimeReporter.settled(observedSpawn ? spawnCompleted ? 'completed' : 'failed' : null);
1834
1875
  commitAttribution?.cleanup();
1835
1876
  } finally {
1836
1877
  if (spawnCompleted && skillMount) {
@@ -1994,15 +2035,32 @@ export const performRun = ({
1994
2035
  } else {
1995
2036
  // ADR-018 length gate: the tone contract is enforced here, where the
1996
2037
  // wrapper is the one posting. Split, attach — never truncate.
1997
- const delivery = await deliverChatReply({
1998
- client,
1999
- podId: eventPodId,
2000
- text: replyText,
2001
- limit: chatCharLimit,
2002
- maxChunks: maxChatChunks,
2003
- uploadName: `${agentName}-reply-${event._id}.md`,
2004
- log: (line) => log(`[${event.type}] ${line}`),
2005
- });
2038
+ let delivery;
2039
+ try {
2040
+ delivery = await deliverChatReply({
2041
+ client,
2042
+ podId: eventPodId,
2043
+ text: replyText,
2044
+ limit: chatCharLimit,
2045
+ maxChunks: maxChatChunks,
2046
+ uploadName: `${agentName}-reply-${event._id}.md`,
2047
+ log: (line) => log(`[${event.type}] ${line}`),
2048
+ });
2049
+ } catch (deliveryError) {
2050
+ // TASK-535: every rung threw, so the reply reached nobody. The throw
2051
+ // still leaves the events unacked for redelivery (unchanged), but the
2052
+ // retry report is about the BATCH; this line is about the reply, and
2053
+ // it names where it was going so a lost summary is never silent.
2054
+ const eventCount = Array.isArray(event.payload?.batchEventIds)
2055
+ ? event.payload.batchEventIds.length
2056
+ : 1;
2057
+ log(
2058
+ `[${event.type}] ERROR reply NOT delivered to pod ${eventPodId} — `
2059
+ + `${Buffer.byteLength(replyText)} bytes lost, ${eventCount} event${eventCount === 1 ? '' : 's'} `
2060
+ + `left unacked for retry: ${deliveryError.message}`,
2061
+ );
2062
+ throw deliveryError;
2063
+ }
2006
2064
  if (delivery.skipped) {
2007
2065
  // The server answered 200 and created nothing: a duplicate inside its
2008
2066
  // dedupe window, a silent body, a heartbeat it keeps. Nothing reached
@@ -3537,6 +3595,11 @@ Docs:
3537
3595
  // TASK-102 part B: the operator's TTL ask, if any. Unset means the
3538
3596
  // server's published default, which is the only lifetime we can justify
3539
3597
  // without an operator saying otherwise.
3598
+ runtimeReportClient: createMachineReportClient({ instanceUrl: record.instanceUrl,
3599
+ machineId: record.machineId, agentName: record.agentName, instanceId: record.instanceId }),
3600
+ resolveOwnProviderKey: createOwnKeyResolver({ instanceUrl: record.instanceUrl,
3601
+ machineId: record.machineId, agentName: record.agentName, instanceId: record.instanceId }),
3602
+ reportRuntime: Boolean(record.machineId || process.env.COMMONLY_MANAGED_RUNNER === '1'),
3540
3603
  spawnCredentialTtlSeconds: resolveSpawnTtlSeconds(process.env),
3541
3604
  refusalHandoff: opts.refusalHandoff || null,
3542
3605
  log: (line) => console.log(`${stamp()} [${name}] ${line}`),
@@ -1,3 +1,4 @@
1
+ import { collectRuntimeInventory } from '../lib/runtime-inventory.js';
1
2
  /**
2
3
  * commonly daemon <subcommand>
3
4
  *
@@ -474,6 +475,8 @@ Examples:
474
475
  // exit parks it on macOS; systemd honors 77 directly.
475
476
  process.exitCode = saved || process.platform !== 'darwin' ? 77 : 0;
476
477
  };
478
+ let cachedInventory;
479
+ let inventoryCheckedAt = 0;
477
480
  supervisor = createDaemonSupervisor({
478
481
  record,
479
482
  client,
@@ -488,10 +491,18 @@ Examples:
488
491
  chmodSync(seatLog, 0o600);
489
492
  return spawn(process.execPath, [process.argv[1], 'agent', 'run', agentName], {
490
493
  stdio: ['ignore', out, out],
494
+ env: { ...process.env, COMMONLY_MANAGED_RUNNER: '1' },
491
495
  });
492
496
  },
493
497
  loadToken: loadAgentToken,
494
498
  saveToken: saveAgentToken,
499
+ runtimeInventory: async () => {
500
+ if (!cachedInventory || Date.now() - inventoryCheckedAt > 300_000) {
501
+ cachedInventory = await collectRuntimeInventory(getAdapter);
502
+ inventoryCheckedAt = Date.now();
503
+ }
504
+ return cachedInventory;
505
+ },
495
506
  resolveAdapter: (runtime) => resolveAdapterForRuntime(runtime),
496
507
  log: stampLog,
497
508
  persistState: (seats) => saveDaemonState({
@@ -1,3 +1,5 @@
1
+ import { claudeRuntimeInventory } from '../native-runtime-inventory.js';
2
+ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
1
3
  /**
2
4
  * claude adapter — wraps the local `claude` CLI as a Commonly agent.
3
5
  *
@@ -289,12 +291,13 @@ const publicSkillPromptArgs = async (skillSources) => {
289
291
  return { args: text ? ['--append-system-prompt', text] : [], included };
290
292
  };
291
293
 
292
- const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
294
+ const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
293
295
  const proc = spawnImpl(cmd, args, {
294
296
  cwd,
295
297
  env,
296
298
  stdio: ['ignore', 'pipe', 'pipe'],
297
299
  });
300
+ observeSpawn(proc, onSpawn, controls);
298
301
  let stdout = '';
299
302
  let stderr = '';
300
303
  let timedOut = false;
@@ -736,11 +739,13 @@ export default {
736
739
  // hosted variant.
737
740
  runtimeType: 'claude-code',
738
741
 
742
+ inventory: () => claudeRuntimeInventory({ env: withClaudePath(process.env) }),
743
+
739
744
  async detect() {
740
745
  try {
741
746
  const claudeEnv = withClaudePath(process.env);
742
747
  const res = spawnSync('claude', ['--version'], {
743
- encoding: 'utf8',
748
+ encoding: 'utf8', timeout: 5000,
744
749
  env: claudeEnv,
745
750
  });
746
751
  if (res.error || res.status !== 0) return null;
@@ -900,7 +905,7 @@ export default {
900
905
  // and its provider key is not an env var here, so the seat credential is
901
906
  // the value a tail can echo back.
902
907
  credentials: spawnCredentials(spawnCtx),
903
- spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
908
+ spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
904
909
  });
905
910
  // TASK-440: the context this turn left, read from the transcript the
906
911
  // child wrote under its own env (an isolated HOME for public seats).
@@ -951,7 +956,7 @@ export default {
951
956
  env: retry.env,
952
957
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
953
958
  credentials: spawnCredentials(retryCtx),
954
- spawnImpl: ctx._spawnImpl,
959
+ spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
955
960
  });
956
961
  const contextTokens = claudeContextTokens({
957
962
  cwd: ctx.cwd, sessionId: freshId,
@@ -1,3 +1,5 @@
1
+ import { codexRuntimeInventory } from '../native-runtime-inventory.js';
2
+ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
1
3
  /**
2
4
  * codex adapter — wraps the local `codex` CLI as a Commonly agent.
3
5
  *
@@ -570,7 +572,7 @@ const CODEX_PROVIDER_KEY_ENVS = [
570
572
  'CODEX_API_KEY',
571
573
  ];
572
574
 
573
- const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
575
+ const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
574
576
  // defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
575
577
  // `Reading additional input from stdin...` because it sees an open pipe
576
578
  // and waits for input that never arrives. Interactive runs are fine because
@@ -578,6 +580,7 @@ const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = chi
578
580
  // `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
579
581
  // argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
580
582
  const proc = spawnImpl('codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
583
+ observeSpawn(proc, onSpawn, controls);
581
584
  let stderr = '';
582
585
  let timedOut = false;
583
586
  const events = makeEventParser();
@@ -625,9 +628,11 @@ export default {
625
628
  // `runtimeType` and differ only on `host`.
626
629
  runtimeType: 'codex',
627
630
 
631
+ inventory: codexRuntimeInventory,
632
+
628
633
  async detect() {
629
634
  try {
630
- const res = spawnSync('codex', ['--version'], { encoding: 'utf8' });
635
+ const res = spawnSync('codex', ['--version'], { encoding: 'utf8', timeout: 5000 });
631
636
  if (res.error || res.status !== 0) return null;
632
637
  // `codex --version` prints e.g. "codex-cli 0.125.0" — last token is
633
638
  // the version. Defensive against future format tweaks: if no
@@ -734,7 +739,7 @@ export default {
734
739
  // already removed the token from the child copy, and after that there is
735
740
  // nothing left to compare a tail against.
736
741
  credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
737
- spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
742
+ spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
738
743
  });
739
744
 
740
745
  let text = '';
@@ -1,3 +1,4 @@
1
+ import { observeSpawn } from '../runtime-observation.js';
1
2
  /** DeepSeek Harness 0.2.1-rc.2, private seats only. Configuration is per spawn. */
2
3
  import { requireSeatModel } from './seat-model.js';
3
4
  import { spawn as childSpawn, spawnSync } from 'child_process';
@@ -167,8 +168,9 @@ const parseEvent = (line, state) => {
167
168
  }
168
169
  };
169
170
 
170
- export const runDsh = ({ binary, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
171
+ export const runDsh = ({ binary, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
171
172
  const proc = spawnImpl(binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
173
+ observeSpawn(proc, onSpawn, controls);
172
174
  const state = { sessionId: null, final: null, error: null, buffer: '' };
173
175
  let stderr = '';
174
176
  let timedOut = false;
@@ -277,11 +279,14 @@ export default {
277
279
  const mcp = resolveCommonlyMcp(ctx.environment?.mcp, {
278
280
  credentialFile: credential.path, instanceUrl: ctx.instanceUrl,
279
281
  });
280
- const patch = JSON.stringify(buildPatch({
282
+ const patchData = buildPatch({
281
283
  proxyBaseURL: proxy.baseURL, model: seatModel,
282
284
  cwd: ctx.cwd, mcp, effort: ctx.environment.effort,
283
285
  deepseekOfficial: new URL(ctx.environment.provider.baseURL).hostname === 'api.deepseek.com',
284
- }), null, 2);
286
+ });
287
+ const patch = JSON.stringify(patchData, null, 2);
288
+ const passedProvider = patchData.find((entry) => entry.id === 'llm-pi-ai').config.providers.commonly;
289
+ const passedModel = patchData.find((entry) => entry.id === 'agent-default-model').config.model;
285
290
  assertPatchHasNoCredentials(patch, [ctx.runtimeToken, bearer]);
286
291
  const patchPath = join(tempDir, 'seat.patch.yml');
287
292
  await writeFile(patchPath, `${patch}\n`, { mode: 0o600 });
@@ -296,7 +301,7 @@ export default {
296
301
  ? Math.min(requested, MAX_TIMEOUT_MS) : MAX_TIMEOUT_MS;
297
302
  return await runDsh({
298
303
  binary, args, cwd: ctx.cwd, env, timeoutMs,
299
- credentials: [...spawnCredentials(ctx, []), bearer], spawnImpl: ctx._spawnImpl,
304
+ credentials: [...spawnCredentials(ctx, []), bearer], spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: { model: passedModel || null, effort: passedProvider.reasoning || null },
300
305
  });
301
306
  } finally {
302
307
  try { await proxy?.close(); } catch { /* best effort */ }
@@ -1,3 +1,4 @@
1
+ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
1
2
  /** Hermes 0.22.2 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */
2
3
  import { requireSeatModel, seatModelRequired } from './seat-model.js';
3
4
  import { spawn as childSpawn, spawnSync } from 'child_process';
@@ -49,10 +50,11 @@ export const readHermesJsonl = (line, state) => {
49
50
  }
50
51
  };
51
52
 
52
- const runHermes = ({ args, prompt, cwd, env, timeoutMs, spawnImpl, credentials }) => new Promise((resolve, reject) => {
53
+ const runHermes = ({ args, prompt, cwd, env, timeoutMs, spawnImpl, credentials, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
53
54
  const child = (spawnImpl || childSpawn)('hermes', args, {
54
55
  cwd, env, stdio: ['pipe', 'pipe', 'pipe'],
55
56
  });
57
+ observeSpawn(child, onSpawn, controls);
56
58
  const state = { started: false, result: null };
57
59
  const decoder = new StringDecoder('utf8');
58
60
  let buffer = '';
@@ -190,7 +192,7 @@ export default {
190
192
  ? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
191
193
  return runHermes({
192
194
  args, prompt: fullPrompt, cwd: ctx.cwd, env, timeoutMs,
193
- spawnImpl: ctx._spawnImpl,
195
+ spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }),
194
196
  credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']),
195
197
  });
196
198
  },
@@ -1,3 +1,4 @@
1
+ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
1
2
  /**
2
3
  * OpenCode adapter — wraps `opencode run --format json` as a Commonly seat.
3
4
  *
@@ -393,7 +394,7 @@ const publicToolFailedError = ({ tool, error }) => {
393
394
  return failure;
394
395
  };
395
396
 
396
- const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
397
+ const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
397
398
  let proc;
398
399
  try {
399
400
  proc = spawnImpl(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
@@ -401,6 +402,7 @@ const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl =
401
402
  reject(err);
402
403
  return;
403
404
  }
405
+ observeSpawn(proc, onSpawn, controls);
404
406
  let stderr = '';
405
407
  let timedOut = false;
406
408
  let killTimer = null;
@@ -634,7 +636,7 @@ export default {
634
636
 
635
637
  async detect() {
636
638
  try {
637
- const res = spawnSync('opencode', ['--version'], { encoding: 'utf8' });
639
+ const res = spawnSync('opencode', ['--version'], { encoding: 'utf8', timeout: VERSION_CHECK_TIMEOUT_MS });
638
640
  if (res.error || res.status !== 0) return null;
639
641
  const stdout = (res.stdout || '').trim();
640
642
  const version = parseOpenCodeVersion(stdout) || stdout || 'unknown';
@@ -666,7 +668,10 @@ export default {
666
668
  if (isPublic && !provider) {
667
669
  throw new Error('public OpenCode seats require environment.provider; the implicit public tier and operator auth.json are unsupported for public trust');
668
670
  }
669
- const providerKeyFile = await validateProviderEnvironment(ctx.environment, ctx.cwd);
671
+ if (ctx.providerKey && (!isPublic || !['workspace', 'read-only', 'bwrap'].includes(sandboxMode))) {
672
+ throw new Error('Own provider keys require a confined OpenCode runtime');
673
+ }
674
+ const providerKeyFile = ctx.providerKey ? null : await validateProviderEnvironment(ctx.environment, ctx.cwd);
670
675
 
671
676
  const sourceEnv = ctx.env || process.env;
672
677
  const childSourceEnv = Object.fromEntries(Object.entries(sourceEnv)
@@ -739,7 +744,7 @@ export default {
739
744
  // the bearer only prevents unrelated local processes from using it.
740
745
  providerProxy = await startProviderProxy({
741
746
  provider,
742
- keyFile: providerKeyFile,
747
+ keyFile: providerKeyFile, keyMaterial: ctx.providerKey,
743
748
  tokenFile: join(tempDir, 'provider-proxy-token'),
744
749
  ...(sandboxMode === 'bwrap' ? {
745
750
  socketPath: join(tempDir, 'provider-proxy.sock'),
@@ -868,7 +873,7 @@ export default {
868
873
  });
869
874
  [cmd, ...spawnArgs] = wrapped;
870
875
  }
871
- const credentials = spawnCredentials(ctx, sourceAuthEnvNames(sourceEnv));
876
+ const credentials = [...spawnCredentials(ctx, sourceAuthEnvNames(sourceEnv)), ctx.providerKey].filter(Boolean);
872
877
  const result = await runOpenCode({
873
878
  cmd,
874
879
  args: spawnArgs,
@@ -876,7 +881,7 @@ export default {
876
881
  env: childEnv,
877
882
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
878
883
  credentials,
879
- spawnImpl: ctx._spawnImpl,
884
+ spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(0, -1), { modelFlag: '-m', effortFlag: '--variant', valueFlags: ['-s', '--dir', '--title', '--format'] }),
880
885
  });
881
886
  if (isPublic) {
882
887
  const failure = publicToolFailure(result.toolErrors, ctx.environment?.mcp);
@@ -64,6 +64,7 @@ export const createDaemonSupervisor = ({
64
64
  spawnChild, // (agentName) => child emitting 'exit'; must expose .kill()
65
65
  loadToken, // (agentName) => token record | null
66
66
  saveToken, // (agentName, record) => void
67
+ runtimeInventory = async () => undefined,
67
68
  resolveAdapter, // async (runtime) => adapter name for THIS machine
68
69
  persistState = () => {}, // (agentStates) => void; must not persist secrets
69
70
  log = () => {},
@@ -343,6 +344,7 @@ export const createDaemonSupervisor = ({
343
344
  const workspacePath = workspacePathFor(nextEnvironment);
344
345
  const nextRecord = {
345
346
  ...existing,
347
+ machineId: record.machineId,
346
348
  ...(adapterChanged ? { adapter: nextAdapter } : {}),
347
349
  environment: nextEnvironment,
348
350
  ...(workspacePath ? { workspacePath } : {}),
@@ -445,6 +447,7 @@ export const createDaemonSupervisor = ({
445
447
  instanceId: row.instanceId,
446
448
  runtimeToken: minted.token,
447
449
  instanceUrl: record.instanceUrl,
450
+ machineId: record.machineId,
448
451
  podId: row.podIds?.[0] || null,
449
452
  adapter,
450
453
  ...(recordEnvironment ? { environment: recordEnvironment } : {}),
@@ -552,7 +555,7 @@ export const createDaemonSupervisor = ({
552
555
  const heartbeat = async () => {
553
556
  if (stopped || now() < reconnectAfter) return;
554
557
  try {
555
- await client.post(`/api/machines/${record.machineDbId}/heartbeat`, { agents: agentStates() });
558
+ await client.post(`/api/machines/${record.machineDbId}/heartbeat`, { agents: agentStates(), runtimeInventory: await runtimeInventory() });
556
559
  connectionRestored();
557
560
  } catch (error) {
558
561
  connectionFailure(error, 'heartbeat failed');
@@ -18,6 +18,8 @@
18
18
  * stands the agent down, and both are logged with the reason.
19
19
  */
20
20
 
21
+ import { isUnseenMessagesHold } from './pod-catch-up.js';
22
+
21
23
  // Event types whose payload.messageId identifies a claimable trigger message.
22
24
  // first_contact is deliberately absent: the welcome wake targets exactly one
23
25
  // agent, so there is nothing to contend for. Heartbeats have no message at
@@ -673,7 +675,49 @@ export const deliverChatReply = async ({
673
675
  // request, but no message was created. Keep that distinction at the client
674
676
  // boundary so every delivery mode shares it rather than treating a resolved
675
677
  // promise as proof of a post.
676
- const postMessage = (body) => client.post(messagesPath, body);
678
+ //
679
+ // TASK-535 — the send-time freshness hold. The backend answers 409
680
+ // `unseen_messages` when other members posted after this seat's last
681
+ // recorded tail read. A turn that ran ~43 minutes (rhea, 2026-10-25 10:01Z)
682
+ // hit it on the thread headline, the attach card and the split fallback in
683
+ // turn, so the reply was lost and its 10 events re-ran. A retry without an
684
+ // override cannot succeed — every rung posts to the same guarded route.
685
+ //
686
+ // CTO ruling: on the FIRST hold of a delivery, re-read the pod tail once,
687
+ // then post with `continueAnyway: true`, and keep the override for every
688
+ // later post in THIS delivery (remaining chunks, later rungs). The re-read
689
+ // is GET /messages at the route's 50-row cap with no `before`/`threadRootId`
690
+ // — the read that records a seen cursor (AgentSendFreshness.recordRead). It
691
+ // advances the cursor only when the page holds EVERY message since the last
692
+ // recorded read. The wrapper's own post-turn snapshot is that same read at
693
+ // limit 10, which is why a 12-message gap stayed unseen. So a re-read alone
694
+ // passes a normal post only while the gap is ≤ 50 and nothing lands between
695
+ // read and post; the override is what delivers, and the read records what
696
+ // the wrapper saw, exactly as its snapshots already do. One re-read and one
697
+ // override per delivery; a post that still fails throws to the next rung as
698
+ // before. Nothing here loops.
699
+ let freshnessOverride = false;
700
+ const postMessage = async (body) => {
701
+ if (freshnessOverride) return client.post(messagesPath, { ...body, continueAnyway: true });
702
+ try {
703
+ return await client.post(messagesPath, body);
704
+ } catch (error) {
705
+ if (!isUnseenMessagesHold(error)) throw error;
706
+ freshnessOverride = true;
707
+ const unseen = error.body?.unseenCount;
708
+ log(
709
+ `reply held: ${Number.isFinite(unseen) ? unseen : 'some'} unseen message(s) in pod ${podId} `
710
+ + 'arrived during the turn — re-reading the pod once, then posting with continueAnyway (TASK-535)',
711
+ );
712
+ try {
713
+ await client.get(messagesPath, { limit: 50 });
714
+ } catch (readError) {
715
+ // The re-read is bookkeeping, not permission: the override delivers.
716
+ log(`pod re-read before the continueAnyway post failed (${readError.message}) — posting anyway`);
717
+ }
718
+ return client.post(messagesPath, { ...body, continueAnyway: true });
719
+ }
720
+ };
677
721
  const refused = (response, messages, attemptedMessages) => ({
678
722
  mode: 'refused',
679
723
  messages,
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export const claudeEffortsFromHelp = (help) => {
7
+ const section = String(help).match(/--effort\s+<[^>]+>([\s\S]*?)(?=\n\s+--|$)/)?.[1];
8
+ const declared = section?.match(/\((low(?:\s*,\s*(?:medium|high|xhigh|max))*)\)/)?.[1];
9
+ return declared ? declared.split(',').map((level) => level.trim()) : [];
10
+ };
11
+ export const codexModelsFromCache = (cache) => {
12
+ const models = Array.isArray(cache?.models) ? cache.models.filter((row) => typeof row.slug === 'string'
13
+ && row.slug.length > 0 && row.slug.length <= 256 && row.visibility !== 'hidden') : [];
14
+ const levels = models.map((row) => (Array.isArray(row.supported_reasoning_levels)
15
+ ? row.supported_reasoning_levels.flatMap((level) => typeof level.effort === 'string' ? [level.effort] : []) : []));
16
+ return { models: models.length ? models.map((row) => row.slug) : null,
17
+ // Only levels supported by every advertised model are offered globally.
18
+ efforts: levels.length ? levels[0].filter((level) => levels.every((list) => list.includes(level))) : [] };
19
+ };
20
+ export const claudeRuntimeInventory = async ({ spawnSyncImpl = spawnSync, env = process.env } = {}) => {
21
+ const result = spawnSyncImpl('claude', ['--help'], {
22
+ encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024, cwd: tmpdir(), env,
23
+ });
24
+ return { models: null, efforts: result.status === 0 ? claudeEffortsFromHelp(result.stdout) : [] };
25
+ };
26
+ export const codexRuntimeInventory = async ({ readFileImpl = readFile, statImpl = stat, env = process.env } = {}) => {
27
+ // Native cache only: no command that could execute workspace extensions.
28
+ const path = join(env.CODEX_HOME || join(homedir(), '.codex'), 'models_cache.json');
29
+ try {
30
+ if ((await statImpl(path)).size > 2 * 1024 * 1024) return { models: null, efforts: [] };
31
+ return codexModelsFromCache(JSON.parse(await readFileImpl(path, 'utf8')));
32
+ } catch { return { models: null, efforts: [] }; }
33
+ };
@@ -0,0 +1,42 @@
1
+ import { loadDaemonRecord } from './daemon-store.js';
2
+ import { createClient } from './api.js';
3
+
4
+ // The daemon bearer stays in this parent closure. Seat tokens can be inherited
5
+ // by model children during mint fallback, so they never authorize key reads.
6
+ export const createOwnKeyResolver = ({ instanceUrl, machineId, agentName, instanceId = 'default',
7
+ loadRecord = loadDaemonRecord, clientFactory = createClient }) => async ({ opened, harness, provider }) => {
8
+ if (harness !== 'opencode' || !provider) return undefined;
9
+ const record = loadRecord();
10
+ if (!record || (machineId && record.machineId !== machineId)
11
+ || new URL(record.instanceUrl).href !== new URL(instanceUrl).href) {
12
+ throw new Error('Own provider key requires this machine’s registered daemon');
13
+ }
14
+ let launch;
15
+ try {
16
+ launch = await clientFactory({ instance: instanceUrl, token: record.daemonToken })
17
+ .post('/api/agents/runtime/provider-key/launch', { agentName, instanceId, harness,
18
+ providerId: provider.id, baseURL: provider.baseURL, metadataOnly: opened?.source !== 'spawn' });
19
+ } catch (error) {
20
+ if ([404, 405].includes(error.status)) return undefined;
21
+ throw new Error('Own provider key could not be resolved; retry after checking Settings');
22
+ }
23
+ if (!launch?.set) return undefined;
24
+ if (opened?.source !== 'spawn') {
25
+ const error = new Error('Own provider key requires a scoped spawn credential; turn was not started');
26
+ error.ownKeySpawnRefused = true;
27
+ throw error;
28
+ }
29
+ return launch.key;
30
+ };
31
+
32
+ // Parent-only receipt client. Failure to load machine authority disables
33
+ // diagnostics; it must never fall back to a seat bearer.
34
+ export const createMachineReportClient = ({ instanceUrl, machineId, agentName, instanceId = 'default' }) => {
35
+ try {
36
+ const record = loadDaemonRecord();
37
+ if (!record || (machineId && machineId !== record.machineId)
38
+ || new URL(record.instanceUrl).href !== new URL(instanceUrl).href) return null;
39
+ const client = createClient({ instance: instanceUrl, token: record.daemonToken });
40
+ return { post: (path, body) => client.post(path, { ...body, agentName, instanceId }) };
41
+ } catch { return null; }
42
+ };
@@ -1,3 +1,13 @@
1
+ /**
2
+ * The backend's send-time freshness hold (agentSendFreshness.unseenMessages):
3
+ * HTTP 409 with `code: 'unseen_messages'`, surfaced by api.js as
4
+ * `error.status` + `error.body`. Shared by the turn-start probe below and the
5
+ * end-of-turn delivery in enforcement.js, so both read the hold the same way.
6
+ */
7
+ export const isUnseenMessagesHold = (error) => (
8
+ error?.status === 409 && error?.body?.code === 'unseen_messages'
9
+ );
10
+
1
11
  /** Explicit turn-start recovery for history that cannot fit in a tail read. */
2
12
  export const catchUpBeforeTurn = async (client, podId) => {
3
13
  const base = `/api/agents/runtime/pods/${encodeURIComponent(podId)}`;
@@ -9,13 +19,19 @@ export const catchUpBeforeTurn = async (client, podId) => {
9
19
  // Unknown freshness preserves existing turn behavior; the send-time auth
10
20
  // and freshness checks still decide whether output may be posted.
11
21
  if (error?.status !== 409) return '';
12
- if (error?.body?.code !== 'unseen_messages') throw error;
22
+ if (!isUnseenMessagesHold(error)) throw error;
13
23
  unseenCount = error.body.unseenCount;
14
24
  }
15
25
  // Routine wakes retain the seat's existing read behavior and prompt size.
16
26
  if (!(unseenCount > 50)) return '';
17
- // Never retry an already generated reply with continueAnyway. A catch-up
18
- // supplies fresh conversation context BEFORE the model gets a turn.
27
+ // Before the turn, the answer to a hold is context, not an override: a
28
+ // catch-up supplies fresh conversation BEFORE the model gets a turn, so
29
+ // nothing here sends with continueAnyway. The one place an already
30
+ // generated reply IS retried with continueAnyway is end-of-turn delivery
31
+ // (deliverChatReply, TASK-535): a turn long enough for the pod to move on
32
+ // otherwise lost its whole reply to this same hold and left its events to
33
+ // re-run. That is a CTO ruling scoped to delivery; it does not license
34
+ // continueAnyway anywhere a fresh read can still reach the model.
19
35
  let body;
20
36
  try {
21
37
  body = await client.post(`${base}/catch-up`, { confirmSkipHistory: true });
@@ -1,3 +1,4 @@
1
+ import { Transform } from 'node:stream';
1
2
  /** The per-spawn loopback proxy shared by OpenAI-compatible CLI adapters. */
2
3
  import { randomBytes, timingSafeEqual } from 'crypto';
3
4
  import { constants as fsConstants, realpathSync } from 'fs';
@@ -6,6 +7,30 @@ import { request as httpsRequest } from 'https';
6
7
  import { chmod, lstat, open, unlink, writeFile } from 'fs/promises';
7
8
  import { isAbsolute, relative, sep } from 'path';
8
9
 
10
+ // Providers sometimes echo request headers in an error response. Redact the
11
+ // material across arbitrary stream chunk boundaries before any model sees it.
12
+ export const redactProviderMaterial = (material) => {
13
+ const secret = Buffer.from(material);
14
+ let pending = Buffer.alloc(0);
15
+ return new Transform({
16
+ transform(chunk, _encoding, done) {
17
+ pending = Buffer.concat([pending, Buffer.from(chunk)]);
18
+ while (pending.length >= secret.length) {
19
+ const index = pending.indexOf(secret);
20
+ if (index >= 0) {
21
+ this.push(pending.subarray(0, index)); this.push('[redacted]');
22
+ pending = pending.subarray(index + secret.length);
23
+ } else {
24
+ const count = pending.length - secret.length + 1;
25
+ this.push(pending.subarray(0, count)); pending = pending.subarray(count);
26
+ }
27
+ }
28
+ done();
29
+ },
30
+ flush(done) { this.push(pending); pending = Buffer.alloc(0); done(); },
31
+ });
32
+ };
33
+
9
34
  const isPathWithin = (parent, candidate) => {
10
35
  const fromParent = relative(parent, candidate);
11
36
  return fromParent === '' || (fromParent !== '..'
@@ -68,7 +93,7 @@ const readProviderKey = async (keyFile, adapterLabel) => {
68
93
  };
69
94
 
70
95
  export const startProviderProxy = async ({
71
- provider, keyFile, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode',
96
+ provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode',
72
97
  }) => {
73
98
  const upstream = new URL(provider.baseURL);
74
99
  if (socketPath && (!isAbsolute(socketPath)
@@ -80,7 +105,8 @@ export const startProviderProxy = async ({
80
105
  throw new Error(`${adapterLabel} provider.baseURL must be an http(s) URL without embedded credentials or query data`);
81
106
  }
82
107
  const basePath = upstream.pathname.replace(/\/+$/, '') || '/';
83
- const upstreamKey = await readProviderKey(keyFile, adapterLabel);
108
+ const upstreamKey = keyMaterial === undefined ? await readProviderKey(keyFile, adapterLabel) : keyMaterial;
109
+ if (typeof upstreamKey !== 'string' || !upstreamKey || /[\r\n]/.test(upstreamKey)) throw new Error('Invalid provider key');
84
110
  const proxyToken = randomBytes(32).toString('base64url');
85
111
  await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
86
112
  await chmod(tokenFile, 0o600);
@@ -176,6 +202,7 @@ export const startProviderProxy = async ({
176
202
  ].includes(name.toLowerCase())));
177
203
  headers.authorization = `Bearer ${upstreamKey}`;
178
204
  headers.host = target.host;
205
+ headers['accept-encoding'] = 'identity';
179
206
  const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
180
207
  protocol: target.protocol,
181
208
  hostname: target.hostname,
@@ -189,13 +216,21 @@ export const startProviderProxy = async ({
189
216
  outgoing.writeHead(502).end('Provider redirects are not supported');
190
217
  return;
191
218
  }
219
+ if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') {
220
+ response.resume(); outgoing.writeHead(502).end('Compressed provider responses are not supported'); return;
221
+ }
192
222
  const responseHeaders = Object.fromEntries(Object.entries(response.headers)
193
223
  .filter(([name]) => ![
194
224
  'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
195
225
  'te', 'trailer', 'transfer-encoding', 'upgrade',
196
226
  ].includes(name.toLowerCase())));
227
+ delete responseHeaders['content-length'];
228
+ for (const [name, value] of Object.entries(responseHeaders)) {
229
+ if (typeof value === 'string') responseHeaders[name] = value.split(upstreamKey).join('[redacted]');
230
+ else if (Array.isArray(value)) responseHeaders[name] = value.map((entry) => entry.split(upstreamKey).join('[redacted]'));
231
+ }
197
232
  outgoing.writeHead(response.statusCode || 502, responseHeaders);
198
- response.pipe(outgoing);
233
+ response.pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
199
234
  });
200
235
  request.on('error', () => {
201
236
  if (!outgoing.headersSent) outgoing.writeHead(502);
@@ -0,0 +1,12 @@
1
+ // Probe only the walked adapter's version command. Never enumerate workspace
2
+ // extensions to discover models: a model picker must not execute project code.
3
+ export const WALKED_HARNESSES = ['claude', 'codex', 'opencode', 'hermes', 'dsh'];
4
+ export const collectRuntimeInventory = async (getAdapter, capabilities) => Promise.all(WALKED_HARNESSES.map(async (harness) => {
5
+ const adapter = getAdapter(harness);
6
+ let detected = null;
7
+ try { detected = await adapter?.detect(); } catch { /* unavailable */ }
8
+ let choices = { models: null, efforts: [] };
9
+ if (detected) { try { choices = (await (capabilities ? capabilities(harness) : adapter?.inventory?.())) || choices; } catch { /* unavailable */ } }
10
+ return { harness, installed: Boolean(detected), version: detected?.version || null,
11
+ models: choices.models, efforts: choices.efforts };
12
+ }));
@@ -0,0 +1,51 @@
1
+ // Read only adapter-owned control arguments. Callers exclude prompt/stdin and
2
+ // provide every value-taking option, so a flag-shaped model/session value is
3
+ // never reinterpreted as a second option. Raw arguments never leave the runner.
4
+ export const controlsFromArguments = (args, { modelFlag, effortFlag, valueFlags = [], codex = false }) => {
5
+ const controls = { model: null, effort: null };
6
+ const flags = new Set([modelFlag, effortFlag, ...valueFlags].filter(Boolean));
7
+ for (let index = 0; index < args.length; index += 1) {
8
+ const flag = args[index];
9
+ if (!flags.has(flag)) continue;
10
+ const value = args[++index];
11
+ if (flag === modelFlag) controls.model = value || null;
12
+ if (flag === effortFlag) controls.effort = value || null;
13
+ if (codex && flag === '-c' && value?.startsWith('model_reasoning_effort=')) {
14
+ try { controls.effort = JSON.parse(value.slice('model_reasoning_effort='.length)); } catch { /* unknown encoding */ }
15
+ }
16
+ }
17
+ return controls;
18
+ };
19
+
20
+ // Called at the child's successful OS spawn event, not when a requested
21
+ // configuration is saved or when a turn is merely queued. Never send argv.
22
+ export const observeSpawn = (child, onSpawn, controls) => {
23
+ if (typeof onSpawn !== 'function') return;
24
+ child.once('spawn', () => {
25
+ try { onSpawn({ model: controls.model || null, effort: controls.effort || null, at: new Date().toISOString() }); } catch { /* diagnostic only */ }
26
+ });
27
+ };
28
+
29
+ export const createRuntimeReporter = ({ client, harness, detect }) => {
30
+ let queue = Promise.resolve();
31
+ const send = (body) => {
32
+ // Serialize so a fast process cannot have its settled report overtake its
33
+ // spawn report. Old backends and diagnostic outages don't stop a turn.
34
+ queue = queue.then(() => client.post('/api/agents/runtime/observation', body)).catch(() => {});
35
+ return queue;
36
+ };
37
+ return {
38
+ async connect() {
39
+ return send({ event: 'connected' });
40
+ },
41
+ spawn: ({ model, effort, at }) => {
42
+ const receipt = { event: 'spawn', harness, version: null, model, effort, ...(at ? { at } : {}) };
43
+ queue = queue.then(async () => {
44
+ try { receipt.version = (await detect())?.version || null; } catch { /* unavailable */ }
45
+ await client.post('/api/agents/runtime/observation', receipt);
46
+ }).catch(() => {});
47
+ return queue;
48
+ },
49
+ settled: (outcome = null) => send({ event: 'settled', ...(outcome ? { outcome } : {}) }),
50
+ };
51
+ };