@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 +1 -1
- package/src/commands/agent.js +87 -8
- package/src/lib/adapters/claude.js +41 -11
- package/src/lib/daemon-supervisor.js +41 -2
- package/src/lib/pod-focus.js +224 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -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(
|
|
423
|
-
environment = { ...environment, ...
|
|
451
|
+
if (environment && Object.keys(environmentRuntime).length) {
|
|
452
|
+
environment = { ...environment, ...environmentRuntime };
|
|
424
453
|
}
|
|
425
|
-
if (!environment && Object.keys(
|
|
426
|
-
environment = { ...
|
|
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(
|
|
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, {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
461
|
+
return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv };
|
|
439
462
|
}
|
|
440
463
|
|
|
441
|
-
return { cmd: 'claude', args: innerArgv, env:
|
|
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
|
|
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'], {
|
|
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 (
|
|
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
|
-
|
|
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
|
+
};
|