@commonlyai/cli 0.1.38 → 0.1.40

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.38",
3
+ "version": "0.1.40",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -33,6 +33,11 @@ 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
35
  import { parseEnvironmentFile, resolveWorkspace, validateEnvironmentSpec } from '../lib/environment.js';
36
+ import {
37
+ FOCUS_FRAME_MAX_CODE_POINTS,
38
+ formatPodFocusFrame,
39
+ readPodFocus,
40
+ } from '../lib/pod-focus.js';
36
41
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
37
42
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
38
43
  import {
@@ -55,6 +60,11 @@ import {
55
60
  resolveCascadeSettings,
56
61
  } from '../lib/enforcement.js';
57
62
 
63
+ const isPodFocusDiagnostic = (error) => (
64
+ typeof error?.code === 'string'
65
+ && (error.code.startsWith('pod_focus') || error.code.startsWith('FOCUS_FRAME_'))
66
+ );
67
+
58
68
  // ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
59
69
 
60
70
  const tokensDir = () => join(homedir(), '.commonly', 'tokens');
@@ -385,27 +395,46 @@ export const setWakeOnMessage = async ({ client, record, enabled }) => {
385
395
  export const updateAgentConfiguration = async ({
386
396
  client,
387
397
  record,
398
+ adapter = null,
388
399
  model = null,
389
400
  effort = null,
390
401
  envPath = null,
391
402
  parseEnv = parseEnvironmentFile,
403
+ adapterRegistry = { getAdapter, listAdapterNames },
392
404
  }) => {
393
405
  if (!record?.podId || !record?.agentName) {
394
406
  throw new Error('token record is missing podId/agentName — re-attach the agent');
395
407
  }
396
408
  const instanceId = record.instanceId || 'default';
397
409
  const runtime = {};
410
+ const environmentRuntime = {};
411
+ if (adapter !== null && adapter !== undefined) {
412
+ const normalizedAdapter = String(adapter).trim().toLowerCase();
413
+ const knownAdapters = adapterRegistry.listAdapterNames();
414
+ const selectedAdapter = adapterRegistry.getAdapter(normalizedAdapter);
415
+ if (!selectedAdapter || !knownAdapters.includes(normalizedAdapter)) {
416
+ throw new Error(
417
+ `Unknown adapter '${normalizedAdapter}'. Known: ${knownAdapters.join(', ')}`,
418
+ );
419
+ }
420
+ if (!await selectedAdapter.detect()) {
421
+ throw new Error(`Adapter '${normalizedAdapter}' not found on PATH. Install it and retry.`);
422
+ }
423
+ runtime.adapter = normalizedAdapter;
424
+ }
398
425
  if (model !== null && model !== undefined) {
399
426
  const normalizedModel = String(model);
400
427
  const validation = validateEnvironmentSpec({ model: normalizedModel });
401
428
  if (!validation.ok) throw new Error(validation.errors.join('; '));
402
429
  runtime.model = normalizedModel;
430
+ environmentRuntime.model = normalizedModel;
403
431
  }
404
432
  if (effort !== null && effort !== undefined) {
405
433
  const normalizedEffort = String(effort);
406
434
  const validation = validateEnvironmentSpec({ effort: normalizedEffort });
407
435
  if (!validation.ok) throw new Error(validation.errors.join('; '));
408
436
  runtime.effort = normalizedEffort;
437
+ environmentRuntime.effort = normalizedEffort;
409
438
  }
410
439
  const config = {};
411
440
  if (Object.keys(runtime).length) config.runtime = runtime;
@@ -419,15 +448,15 @@ export const updateAgentConfiguration = async ({
419
448
  // an ADR-008 environment, a model/effort flag must update that declaration
420
449
  // too; otherwise the daemon would correctly prefer the old explicit value
421
450
  // over the new legacy runtime overlay.
422
- if (environment && Object.keys(runtime).length) {
423
- environment = { ...environment, ...runtime };
451
+ if (environment && Object.keys(environmentRuntime).length) {
452
+ environment = { ...environment, ...environmentRuntime };
424
453
  }
425
- if (!environment && Object.keys(runtime).length) {
426
- environment = { ...runtime };
454
+ if (!environment && Object.keys(environmentRuntime).length) {
455
+ environment = { ...environmentRuntime };
427
456
  }
428
457
  if (environment) config.environment = environment;
429
458
  if (!Object.keys(config).length) {
430
- throw new Error('provide at least one of --model, --effort, or --env');
459
+ throw new Error('provide at least one of --adapter, --model, --effort, or --env');
431
460
  }
432
461
 
433
462
  await client.patch(
@@ -439,6 +468,7 @@ export const updateAgentConfiguration = async ({
439
468
  podId: record.podId,
440
469
  instanceId,
441
470
  changed: Object.keys(config),
471
+ ...(runtime.adapter ? { adapter: runtime.adapter } : {}),
442
472
  ...(environment ? { environment } : {}),
443
473
  };
444
474
  };
@@ -1129,8 +1159,30 @@ export const performRun = ({
1129
1159
  // and (if the adapter returns a summary) patch-sync back after.
1130
1160
  const memoryLongTerm = await readLongTerm(client, { onError });
1131
1161
 
1162
+ // Sharpen TASK-129: focus is a turn-start read, not an enqueue-time
1163
+ // snapshot. Read through the authorized runtime context route immediately
1164
+ // before spawn so queued events observe the current revision. The helper
1165
+ // disables pod-skill synthesis and throws on failure; the surrounding
1166
+ // processing path then leaves this event (or every event in this batch)
1167
+ // unacknowledged for normal delivery retry.
1168
+ let focusRead;
1169
+ let focusFrame;
1170
+ try {
1171
+ focusRead = await readPodFocus(client, eventPodId);
1172
+ focusFrame = formatPodFocusFrame(focusRead);
1173
+ } catch (error) {
1174
+ Object.assign(error, {
1175
+ eventId: event?.payload?.batchEventIds || event?._id || null,
1176
+ podId: eventPodId,
1177
+ focusRevision: focusRead?.revision ?? null,
1178
+ allowedCodePoints: error?.allowedCodePoints || FOCUS_FRAME_MAX_CODE_POINTS,
1179
+ });
1180
+ throw error;
1181
+ }
1182
+ const promptWithFocus = `${focusFrame}\n\n${prompt}`;
1183
+
1132
1184
  log(`[${event.type}] spawning ${adapter.name}`);
1133
- const result = await adapter.spawn(frameDecisionForkRule(prompt), {
1185
+ const result = await adapter.spawn(frameDecisionForkRule(promptWithFocus), {
1134
1186
  sessionId,
1135
1187
  cwd: agentCwd,
1136
1188
  env: process.env,
@@ -1631,6 +1683,16 @@ export const performRun = ({
1631
1683
  retryAfterMs: retry.delayMs,
1632
1684
  circuitOpen: retry.circuitOpen,
1633
1685
  eventId: event._id,
1686
+ ...(isPodFocusDiagnostic(err) ? {
1687
+ focusDiagnostic: {
1688
+ errorCode: err.code,
1689
+ eventIds: err.eventId || group.map((entry) => entry._id),
1690
+ podId: err.podId || group[0]?.podId || podId || null,
1691
+ focusRevision: err.focusRevision ?? null,
1692
+ measuredCodePoints: err.measuredCodePoints ?? null,
1693
+ allowedCodePoints: err.allowedCodePoints ?? FOCUS_FRAME_MAX_CODE_POINTS,
1694
+ },
1695
+ } : {}),
1634
1696
  });
1635
1697
  if (onError) onError(wrapped);
1636
1698
  else log(`[inbox.batch] ${wrapped.message}`);
@@ -1701,6 +1763,16 @@ export const performRun = ({
1701
1763
  retryAfterMs: retry.delayMs,
1702
1764
  circuitOpen: retry.circuitOpen,
1703
1765
  eventId: event._id,
1766
+ ...(isPodFocusDiagnostic(err) ? {
1767
+ focusDiagnostic: {
1768
+ errorCode: err.code,
1769
+ eventIds: err.eventId || event._id,
1770
+ podId: err.podId || event.podId || podId || null,
1771
+ focusRevision: err.focusRevision ?? null,
1772
+ measuredCodePoints: err.measuredCodePoints ?? null,
1773
+ allowedCodePoints: err.allowedCodePoints ?? FOCUS_FRAME_MAX_CODE_POINTS,
1774
+ },
1775
+ } : {}),
1704
1776
  });
1705
1777
  // ONE emission, not two. `wrapped.message` already opens with the
1706
1778
  // event type, so the log copy added a second prefix and a second
@@ -2025,6 +2097,7 @@ Examples:
2025
2097
 
2026
2098
  # List installed agents
2027
2099
  $ commonly agent list
2100
+ $ commonly agent config my-claude --adapter claude --model gpt-5.4 --effort high
2028
2101
  $ commonly agent config my-claude --model gpt-5.4 --effort high
2029
2102
 
2030
2103
  Docs:
@@ -2652,6 +2725,7 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
2652
2725
  agent
2653
2726
  .command('config <name>')
2654
2727
  .description('Update an attached agent\'s server-side runtime configuration')
2728
+ .option('--adapter <name>', 'Local runtime adapter (must be installed on this machine)')
2655
2729
  .option('--model <id>', 'Model identifier to use on the next daemon restart')
2656
2730
  .option('--effort <level>', 'Reasoning effort (low|medium|high|xhigh|max)')
2657
2731
  .option('--env <path>', 'Replace the ADR-008 environment spec with this JSON file')
@@ -2670,12 +2744,17 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
2670
2744
  const result = await updateAgentConfiguration({
2671
2745
  client,
2672
2746
  record,
2747
+ adapter: opts.adapter,
2673
2748
  model: opts.model,
2674
2749
  effort: opts.effort,
2675
2750
  envPath: opts.env ? pathResolve(opts.env) : null,
2676
2751
  });
2677
- if (result.environment) {
2678
- saveAgentToken(name, { ...record, environment: result.environment });
2752
+ if (result.environment || result.adapter) {
2753
+ saveAgentToken(name, {
2754
+ ...record,
2755
+ ...(result.environment ? { environment: result.environment } : {}),
2756
+ ...(result.adapter ? { adapter: result.adapter } : {}),
2757
+ });
2679
2758
  }
2680
2759
  console.log(`✓ Updated ${result.agentName} in pod ${result.podId} (${result.changed.join(', ')})`);
2681
2760
  console.log(' The daemon will apply the change on its next poll; a standalone agent run needs a restart.');
@@ -59,7 +59,7 @@ import {
59
59
  writeFile,
60
60
  } from 'fs/promises';
61
61
  import { homedir, tmpdir } from 'os';
62
- import { isAbsolute, join } from 'path';
62
+ import { delimiter, isAbsolute, join } from 'path';
63
63
 
64
64
  import { mountSkills } from '../environment.js';
65
65
  import { wrapArgvWithBwrap } from '../sandbox/bwrap.js';
@@ -337,17 +337,39 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
337
337
 
338
338
  // ── argv preparation — environment-aware ────────────────────────────────────
339
339
 
340
+ // Claude's npm installer commonly puts the binary in ~/.local/bin, while
341
+ // launchd/supervisor environments intentionally use a small PATH. Keep the
342
+ // command name for the legacy spawn contract, but add this directory to the
343
+ // child environment and to every detection lookup so a daemon does not report
344
+ // a false "adapter unavailable" (or hit spawn ENOENT) merely because it was
345
+ // started outside an interactive shell.
346
+ const withClaudePath = (input) => {
347
+ const output = { ...(input || process.env) };
348
+ const localBin = join(homedir(), '.local', 'bin');
349
+ const entries = String(output.PATH || '')
350
+ .split(delimiter)
351
+ .filter(Boolean);
352
+ if (!entries.includes(localBin)) entries.push(localBin);
353
+ output.PATH = entries.join(delimiter);
354
+ return output;
355
+ };
356
+
340
357
  // Resolve the absolute path of `claude` so bwrap's execvp doesn't depend on
341
358
  // PATH being correctly populated inside the sandbox namespace. Surfaced live
342
359
  // during the 2026-04-17 demo validation: bwrap silently inherits parent
343
360
  // PATH but cannot reach the user's `~/.local/bin` without an absolute path
344
361
  // argv[0], even when that directory is bound read-only into the sandbox.
345
- const resolveClaudePath = () => {
362
+ const resolveClaudePath = (env = process.env) => {
346
363
  // Defensive: spawnSync can return undefined under aggressive mocks (the
347
364
  // adapters.claude.environment.test.mjs suite stubs child_process so no real
348
365
  // process runs). Treat any failure mode as "use the bare command name."
349
366
  let which;
350
- try { which = spawnSync('which', ['claude'], { encoding: 'utf8' }); } catch { /* ignore */ }
367
+ try {
368
+ which = spawnSync('which', ['claude'], {
369
+ encoding: 'utf8',
370
+ env: withClaudePath(env),
371
+ });
372
+ } catch { /* ignore */ }
351
373
  if (which && which.status === 0) {
352
374
  const p = (which.stdout || '').trim();
353
375
  if (p) {
@@ -363,7 +385,8 @@ const resolveClaudePath = () => {
363
385
 
364
386
  const prepareArgv = async (innerArgv, ctx) => {
365
387
  const env = ctx.environment;
366
- if (!env) return { cmd: 'claude', args: innerArgv, env: ctx.claudeEnv };
388
+ const claudeEnv = withClaudePath(ctx.claudeEnv);
389
+ if (!env) return { cmd: 'claude', args: innerArgv, env: claudeEnv };
367
390
 
368
391
  let allowedPatterns = [];
369
392
  if (Array.isArray(env.mcp) && env.mcp.length > 0) {
@@ -407,7 +430,7 @@ const prepareArgv = async (innerArgv, ctx) => {
407
430
  ...innerArgv,
408
431
  ...buildPublicClaudePolicyArgs(allowedPatterns),
409
432
  ];
410
- const claudeBin = resolveClaudePath();
433
+ const claudeBin = resolveClaudePath(claudeEnv);
411
434
  const mcpExecutables = (env.mcp || [])
412
435
  .map((server) => server?.command?.[0])
413
436
  .filter((command) => isAbsolute(command));
@@ -422,7 +445,7 @@ const prepareArgv = async (innerArgv, ctx) => {
422
445
  return {
423
446
  cmd: wrapped[0],
424
447
  args: wrapped.slice(1),
425
- env: ctx.claudeEnv,
448
+ env: claudeEnv,
426
449
  };
427
450
  }
428
451
 
@@ -430,15 +453,15 @@ const prepareArgv = async (innerArgv, ctx) => {
430
453
  innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
431
454
  }
432
455
  if (sandboxMode === 'bwrap') {
433
- const claudeBin = resolveClaudePath();
456
+ const claudeBin = resolveClaudePath(claudeEnv);
434
457
  const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
435
458
  workspacePath: ctx.cwd,
436
459
  readOnlyPaths: ctx.mcpConfigDir ? [ctx.mcpConfigDir] : [],
437
460
  });
438
- return { cmd: wrapped[0], args: wrapped.slice(1), env: ctx.claudeEnv };
461
+ return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv };
439
462
  }
440
463
 
441
- return { cmd: 'claude', args: innerArgv, env: ctx.claudeEnv };
464
+ return { cmd: 'claude', args: innerArgv, env: claudeEnv };
442
465
  };
443
466
 
444
467
  export default {
@@ -453,14 +476,21 @@ export default {
453
476
 
454
477
  async detect() {
455
478
  try {
456
- const res = spawnSync('claude', ['--version'], { encoding: 'utf8' });
479
+ const claudeEnv = withClaudePath(process.env);
480
+ const res = spawnSync('claude', ['--version'], {
481
+ encoding: 'utf8',
482
+ env: claudeEnv,
483
+ });
457
484
  if (res.error || res.status !== 0) return null;
458
485
  // `claude --version` prints e.g. "2.5.1 (Claude Code)" — first token is enough
459
486
  const version = (res.stdout || '').trim().split(/\s+/)[0] || 'unknown';
460
487
  // Best-effort resolve of the binary path for clearer UX ("claude detected
461
488
  // at /usr/local/bin/claude"). Falls back to the bare command name on
462
489
  // platforms without `which` (e.g. Windows).
463
- const where = spawnSync('which', ['claude'], { encoding: 'utf8' });
490
+ const where = spawnSync('which', ['claude'], {
491
+ encoding: 'utf8',
492
+ env: claudeEnv,
493
+ });
464
494
  const path = where.status === 0 ? (where.stdout || '').trim() || 'claude' : 'claude';
465
495
  return { path, version };
466
496
  } catch {
@@ -133,6 +133,26 @@ export const createDaemonSupervisor = ({
133
133
  // once at boot). A row with NO declared model leaves the record alone —
134
134
  // never strip an operator's hand-set environment.
135
135
  const wanted = environmentFor(row);
136
+ const declaredAdapter = row.runtime && typeof row.runtime === 'object'
137
+ && typeof row.runtime.adapter === 'string'
138
+ ? row.runtime.adapter.trim().toLowerCase()
139
+ : null;
140
+ let adapterChanged = false;
141
+ let nextAdapter = existing.adapter;
142
+ if (declaredAdapter) {
143
+ const detectedAdapter = await resolveAdapter(row.runtime || null);
144
+ // resolveAdapterForRuntime historically probes fallbacks when a
145
+ // declared adapter is absent. A configuration edit must never accept
146
+ // that fallback: it would report claude while running codex (or vice
147
+ // versa). Keep the existing child/token untouched until the exact
148
+ // requested adapter is detected locally.
149
+ if (detectedAdapter !== declaredAdapter) {
150
+ log(`[${row.agentName}] requested adapter '${declaredAdapter}' is not available on this machine — keeping the current seat`);
151
+ return false;
152
+ }
153
+ nextAdapter = declaredAdapter;
154
+ adapterChanged = existing.adapter !== nextAdapter;
155
+ }
136
156
  if (wanted) {
137
157
  const nextEnvironment = wanted.declared
138
158
  ? wanted.value
@@ -140,18 +160,37 @@ export const createDaemonSupervisor = ({
140
160
  const workspacePath = workspacePathFor(nextEnvironment);
141
161
  const nextRecord = {
142
162
  ...existing,
163
+ ...(adapterChanged ? { adapter: nextAdapter } : {}),
143
164
  environment: nextEnvironment,
144
165
  ...(workspacePath ? { workspacePath } : {}),
145
166
  };
146
- if (!isDeepStrictEqual(existing.environment || null, nextEnvironment)
167
+ if (adapterChanged
168
+ || !isDeepStrictEqual(existing.environment || null, nextEnvironment)
147
169
  || (workspacePath && existing.workspacePath !== workspacePath)) {
148
170
  saveToken(row.agentName, nextRecord);
149
171
  log('runtime config changed — restarting the seat to load it');
150
172
  return 'changed';
151
173
  }
152
174
  }
175
+ if (adapterChanged) {
176
+ saveToken(row.agentName, { ...existing, adapter: nextAdapter });
177
+ log('runtime adapter changed — restarting the seat to load it');
178
+ return 'changed';
179
+ }
153
180
  return 'ready';
154
181
  }
182
+ const requestedAdapter = row.runtime && typeof row.runtime === 'object'
183
+ && typeof row.runtime.adapter === 'string'
184
+ ? row.runtime.adapter.trim().toLowerCase()
185
+ : null;
186
+ let adapter = null;
187
+ if (requestedAdapter) {
188
+ adapter = await resolveAdapter(row.runtime || null);
189
+ if (adapter !== requestedAdapter) {
190
+ log(`[${row.agentName}] requested adapter '${requestedAdapter}' is not available on this machine — skipping token mint`);
191
+ return false;
192
+ }
193
+ }
155
194
  const body = { agentName: row.agentName, instanceId: row.instanceId };
156
195
  let minted;
157
196
  try {
@@ -174,7 +213,7 @@ export const createDaemonSupervisor = ({
174
213
  log(`[${row.agentName}] mint returned no token — skipping`);
175
214
  return false;
176
215
  }
177
- const adapter = await resolveAdapter(row.runtime || null);
216
+ if (!adapter) adapter = await resolveAdapter(row.runtime || null);
178
217
  if (!adapter) {
179
218
  log(`[${row.agentName}] no usable CLI adapter on this machine — install claude or codex, or attach manually`);
180
219
  return false;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Shared-pod focus bridge for the local CLI driver.
3
+ *
4
+ * The server's runtime context endpoint is the source of truth. This module
5
+ * deliberately keeps the read and the text formatter together so every CLI
6
+ * adapter receives the same bounded projection, including resumed sessions.
7
+ */
8
+
9
+ export const FOCUS_FRAME_MAX_CODE_POINTS = 8000;
10
+ export const FOCUS_TASK_TITLE_MAX_CODE_POINTS = 160;
11
+
12
+ const codePointLength = (value) => Array.from(String(value)).length;
13
+
14
+ const asText = (value, fallback = '') => {
15
+ if (value === null || value === undefined) return fallback;
16
+ return String(value);
17
+ };
18
+
19
+ const truncateCodePoints = (value, max) => {
20
+ const text = asText(value);
21
+ const points = Array.from(text);
22
+ if (points.length <= max) return text;
23
+ if (max <= 1) return '…'.slice(0, max);
24
+ return `${points.slice(0, max - 1).join('')}…`;
25
+ };
26
+
27
+ export class PodFocusError extends Error {
28
+ constructor(message, details = {}, options = {}) {
29
+ super(message, options);
30
+ this.name = 'PodFocusError';
31
+ Object.assign(this, details);
32
+ }
33
+ }
34
+
35
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
36
+
37
+ const invalidContract = (message, details = {}) => new PodFocusError(message, {
38
+ code: 'pod_focus_contract_invalid',
39
+ ...details,
40
+ });
41
+
42
+ const normalizeDto = (dto, podId) => {
43
+ if (!dto || typeof dto !== 'object' || Array.isArray(dto)) {
44
+ throw invalidContract('Runtime context did not return a PodFocusRead object', { podId });
45
+ }
46
+ if (!hasOwn(dto, 'podId') || String(dto.podId) !== String(podId)) {
47
+ throw invalidContract('Runtime context returned a focus for a different pod', {
48
+ podId,
49
+ returnedPodId: dto.podId ?? null,
50
+ });
51
+ }
52
+ if (!hasOwn(dto, 'revision')
53
+ || !Number.isInteger(dto.revision)
54
+ || dto.revision < 0) {
55
+ throw invalidContract('Runtime context returned an invalid focus revision', {
56
+ podId,
57
+ focusRevision: dto.revision ?? null,
58
+ });
59
+ }
60
+ if (!hasOwn(dto, 'focus')
61
+ || (dto.focus !== null
62
+ && (typeof dto.focus !== 'object' || Array.isArray(dto.focus)))) {
63
+ throw invalidContract('Runtime context returned an invalid focus value', {
64
+ podId,
65
+ focusRevision: dto.revision,
66
+ });
67
+ }
68
+ return {
69
+ podId: String(dto.podId),
70
+ revision: dto.revision,
71
+ focus: dto.focus,
72
+ };
73
+ };
74
+
75
+ /**
76
+ * Read only the focus projection from the authorized runtime context route.
77
+ * `skillMode=none` is important: a turn-start focus read must not trigger pod
78
+ * skill synthesis as a side effect. A failed read is intentionally thrown so
79
+ * the caller leaves the event unacknowledged for the existing retry path.
80
+ */
81
+ export const readPodFocus = async (client, podId) => {
82
+ if (!podId) {
83
+ throw new PodFocusError('Pod focus read requires a pod id', {
84
+ code: 'pod_focus_read_failed',
85
+ podId: null,
86
+ });
87
+ }
88
+ try {
89
+ const body = await client.get(
90
+ `/api/agents/runtime/pods/${encodeURIComponent(podId)}/context`,
91
+ { skillMode: 'none' },
92
+ );
93
+ if (!body || typeof body !== 'object' || !hasOwn(body, 'focus')) {
94
+ throw invalidContract('Runtime context did not include the published focus DTO', { podId });
95
+ }
96
+ return normalizeDto(body.focus, podId);
97
+ } catch (cause) {
98
+ // Preserve contract diagnostics instead of relabelling them as transient
99
+ // transport failures. The run loop still retries the queued event, but the
100
+ // operator sees the repair-needed cause and revision metadata.
101
+ if (cause?.code === 'pod_focus_contract_invalid') throw cause;
102
+ throw new PodFocusError(
103
+ `Pod focus read failed for pod ${podId}: ${cause?.message || 'unknown error'}`,
104
+ {
105
+ code: 'pod_focus_read_failed',
106
+ podId: String(podId),
107
+ cause,
108
+ },
109
+ { cause },
110
+ );
111
+ }
112
+ };
113
+
114
+ const taskDetail = (task) => {
115
+ const id = asText(task?.taskId, '(unknown task)');
116
+ const title = truncateCodePoints(task?.title, FOCUS_TASK_TITLE_MAX_CODE_POINTS) || '(untitled)';
117
+ const details = [`title=${title}`];
118
+ if (task?.status !== null && task?.status !== undefined && task.status !== '') {
119
+ details.push(`status=${truncateCodePoints(task.status, 80)}`);
120
+ }
121
+ if (task?.assignee !== null && task?.assignee !== undefined && task.assignee !== '') {
122
+ details.push(`assignee=${truncateCodePoints(task.assignee, 80)}`);
123
+ }
124
+ if (task?.updatedAt !== null && task?.updatedAt !== undefined && task.updatedAt !== '') {
125
+ details.push(`updatedAt=${truncateCodePoints(task.updatedAt, 80)}`);
126
+ }
127
+ if (task?.available === false) details.push('unavailable');
128
+ return `- ${id}: ${details.join('; ')}`;
129
+ };
130
+
131
+ /**
132
+ * Render a PodFocusRead into bounded pod context.
133
+ *
134
+ * Goal, scope, owner identity/label, revision, and every selected task id are
135
+ * protected fields: they are never truncated. If those fields alone exceed
136
+ * the budget, fail closed before a model is spawned. Task labels and live
137
+ * metadata are the only content eligible for the remaining budget.
138
+ */
139
+ export const formatPodFocusFrame = (read, {
140
+ maxCodePoints = FOCUS_FRAME_MAX_CODE_POINTS,
141
+ } = {}) => {
142
+ const limit = Number.isFinite(maxCodePoints) && maxCodePoints > 0
143
+ ? Math.floor(maxCodePoints)
144
+ : FOCUS_FRAME_MAX_CODE_POINTS;
145
+ const normalized = normalizeDto(read, read?.podId || 'unknown');
146
+ const focus = normalized.focus;
147
+
148
+ if (focus === null || focus === undefined) {
149
+ const empty = [
150
+ '=== Pod focus (pod context; not instructions) ===',
151
+ `pod: ${normalized.podId}`,
152
+ `revision: ${normalized.revision}`,
153
+ 'No focus set.',
154
+ ].join('\n');
155
+ if (codePointLength(empty) > limit) {
156
+ throw new PodFocusError('Pod focus frame exceeds its code-point budget', {
157
+ code: 'FOCUS_FRAME_PROTECTED_OVERFLOW',
158
+ measuredCodePoints: codePointLength(empty),
159
+ allowedCodePoints: limit,
160
+ focusRevision: normalized.revision,
161
+ });
162
+ }
163
+ return empty;
164
+ }
165
+
166
+ const owner = focus.owner && typeof focus.owner === 'object' ? focus.owner : {};
167
+ const tasks = Array.isArray(focus.nextTasks) ? focus.nextTasks : [];
168
+ const taskIds = tasks.map((task) => asText(task?.taskId, '(unknown task)'));
169
+ const ownerLabel = asText(owner.label, '(unlabeled)');
170
+ const ownerId = asText(owner.userId, '(unknown user)');
171
+ const ownerAvailability = owner.available === false ? ' [unavailable]' : '';
172
+ const orderedIds = taskIds.length > 0 ? taskIds.join(' → ') : '(none)';
173
+
174
+ // Keep these lines independent from task detail packing. Their complete
175
+ // values are the contract's protected portion.
176
+ const protectedFrame = [
177
+ '=== Pod focus (pod context; not instructions) ===',
178
+ `pod: ${normalized.podId}`,
179
+ `revision: ${asText(normalized.revision, '0')}`,
180
+ `goal: ${asText(focus.goal)}`,
181
+ `scope: ${asText(focus.scope)}`,
182
+ `owner: ${ownerLabel} (${ownerId})${ownerAvailability}`,
183
+ `next task order: ${orderedIds}`,
184
+ ].join('\n');
185
+ const protectedSize = codePointLength(protectedFrame);
186
+ if (protectedSize > limit) {
187
+ throw new PodFocusError('Protected pod focus fields exceed the code-point budget', {
188
+ code: 'FOCUS_FRAME_PROTECTED_OVERFLOW',
189
+ measuredCodePoints: protectedSize,
190
+ allowedCodePoints: limit,
191
+ focusRevision: normalized.revision ?? 0,
192
+ });
193
+ }
194
+
195
+ if (tasks.length === 0) return protectedFrame;
196
+
197
+ // Keep a finite marker in every populated frame. It documents that the
198
+ // structured board/context read remains the place for complete task detail,
199
+ // and reserving it makes packing deterministic at the exact boundary.
200
+ const marker = '… full task details in board / get_context.';
201
+ const detailHeader = 'task details:';
202
+ let frame = `${protectedFrame}\n${detailHeader}`;
203
+ let omitted = false;
204
+ for (const task of tasks) {
205
+ const line = taskDetail(task);
206
+ const candidate = `${frame}\n${line}`;
207
+ const withMarker = `${candidate}\n${marker}`;
208
+ if (codePointLength(withMarker) <= limit) {
209
+ frame = candidate;
210
+ } else {
211
+ omitted = true;
212
+ }
213
+ }
214
+
215
+ // The marker is always useful, and the protected portion was already proven
216
+ // to fit. If there is not enough room for the detail header plus marker,
217
+ // return the protected fields alone rather than slicing them.
218
+ const marked = `${frame}\n${marker}`;
219
+ if (codePointLength(marked) <= limit) return marked;
220
+ if (omitted || codePointLength(`${protectedFrame}\n${detailHeader}\n${marker}`) > limit) {
221
+ return protectedFrame;
222
+ }
223
+ return frame;
224
+ };