@phnx-labs/agents-cli 1.22.106 → 1.22.109
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/CHANGELOG.md +78 -0
- package/README.md +9 -5
- package/dist/commands/daemon.js +45 -40
- package/dist/commands/doctor.js +7 -0
- package/dist/commands/exec.d.ts +78 -8
- package/dist/commands/exec.js +276 -53
- package/dist/commands/monitors.js +15 -2
- package/dist/commands/routines.js +46 -6
- package/dist/commands/run-account-picker.d.ts +11 -0
- package/dist/commands/run-account-picker.js +11 -1
- package/dist/commands/sessions-backup-setup.d.ts +12 -0
- package/dist/commands/sessions-backup-setup.js +65 -0
- package/dist/commands/sessions-resume.d.ts +14 -1
- package/dist/commands/sessions-resume.js +56 -9
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/share.js +15 -2
- package/dist/commands/sync.js +30 -1
- package/dist/lib/accounts/slots.js +7 -0
- package/dist/lib/agent-spec/agents.d.ts +60 -8
- package/dist/lib/agent-spec/agents.js +118 -45
- package/dist/lib/daemon/daemon.d.ts +34 -1
- package/dist/lib/daemon/daemon.js +63 -9
- package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
- package/dist/lib/daemon/leaked-daemons.js +180 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +32 -1
- package/dist/lib/hooks/install.js +70 -63
- package/dist/lib/hosts/dispatch.d.ts +1 -1
- package/dist/lib/hosts/dispatch.js +1 -1
- package/dist/lib/models.js +76 -5
- package/dist/lib/session/cloud.js +3 -1
- package/dist/lib/session/parse.d.ts +1 -0
- package/dist/lib/session/parse.js +136 -2
- package/dist/lib/session/recovery.d.ts +9 -1
- package/dist/lib/session/recovery.js +14 -4
- package/dist/lib/session/tool-calls.d.ts +1 -1
- package/dist/lib/session/tool-calls.js +40 -5
- package/dist/lib/share/backend.d.ts +23 -0
- package/dist/lib/share/backend.js +24 -0
- package/dist/lib/share/provision.d.ts +12 -0
- package/dist/lib/share/provision.js +30 -0
- package/dist/lib/share/worker-template.js +273 -0
- package/dist/lib/terminal/engine.js +13 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentId } from '../types.js';
|
|
2
2
|
import type { DuplicateVersionHook } from '../hooks/install.js';
|
|
3
3
|
import type { AgentsBinaryShadow } from '../binary-shadow.js';
|
|
4
|
+
import type { LeakedDaemon } from '../daemon/leaked-daemons.js';
|
|
4
5
|
import type { RcSecretFinding } from '../secrets-types.js';
|
|
5
6
|
import type { OwnerSinkStatus } from '../channels/owner-sink.js';
|
|
6
7
|
import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
|
|
@@ -13,7 +14,7 @@ export type FindingSeverity = 'critical' | 'warning';
|
|
|
13
14
|
* the JSON consumer group by kind. */
|
|
14
15
|
/** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
|
|
15
16
|
* below owns it, and a second copy in these comments is a fourth place to drift. */
|
|
16
|
-
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "hook-runtime-broken", "hook-runtime-visibility-unavailable", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "auth-bundle-wrong-backend", "exec-policy", "ssh-key-enrollment", "stale-cli", "binary-shadow", "owner-sink-unreachable"];
|
|
17
|
+
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "hook-runtime-broken", "hook-runtime-visibility-unavailable", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "auth-bundle-wrong-backend", "exec-policy", "ssh-key-enrollment", "stale-cli", "binary-shadow", "owner-sink-unreachable", "leaked-daemon"];
|
|
17
18
|
/**
|
|
18
19
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
19
20
|
* the builders below and asserted against both prose rubrics by
|
|
@@ -124,6 +125,11 @@ export interface LocalFindingInputs {
|
|
|
124
125
|
ownerSink?: OwnerSinkStatus;
|
|
125
126
|
/** `agents` binaries that shadow the currently running CLI (RUSH-2431). */
|
|
126
127
|
binaryShadows?: AgentsBinaryShadow[];
|
|
128
|
+
/** `__daemon-run` processes no owner record names — neither the service
|
|
129
|
+
* manager's unit main PID nor the recorded daemon.pid (W4, PHNX-3736).
|
|
130
|
+
* Collected by `findLeakedDaemons` in the command (it runs `ps` and reads
|
|
131
|
+
* /proc, so it stays out of this pure module). */
|
|
132
|
+
leakedDaemons?: LeakedDaemon[];
|
|
127
133
|
}
|
|
128
134
|
/**
|
|
129
135
|
* Fold this machine's signals into findings. Missing hooks/plugins and unwired
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* fleet-resource-gap · hook-runtime-visibility-unavailable · orphan · duplicate-hook ·
|
|
28
28
|
* duplicate-hook-drift · host-cli-missing · host-cli-invalid ·
|
|
29
29
|
* rc-secret-export · env-secret-export · auth-bundle-wrong-backend · exec-policy · stale-cli ·
|
|
30
|
-
* binary-shadow.
|
|
30
|
+
* binary-shadow · leaked-daemon.
|
|
31
31
|
* (RUSH-2162 moved never-synced and duplicate-hook-drift to WARNING: both are
|
|
32
32
|
* stale-sync states one `agents sync` resolves, not "needs you now".)
|
|
33
33
|
*
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* format, so the layout is unit-tested against fixtures with no live fleet.
|
|
39
39
|
*/
|
|
40
40
|
import chalk from 'chalk';
|
|
41
|
+
import * as path from 'path';
|
|
41
42
|
import { AGENTS, ALL_AGENT_IDS, supportsAccountInspection } from '../agents.js';
|
|
42
43
|
import { blocksLocalScripts } from '../platform/winpath.js';
|
|
43
44
|
import { loginHint } from '../signin-badge.js';
|
|
@@ -126,6 +127,7 @@ export const ALL_FINDING_KINDS = [
|
|
|
126
127
|
'stale-cli',
|
|
127
128
|
'binary-shadow', // another agents binary on PATH or in a well-known dir shadows the running copy
|
|
128
129
|
'owner-sink-unreachable', // the feed/notify owner-delivery lane can't reach the owner from this box
|
|
130
|
+
'leaked-daemon', // a `__daemon-run` process no owner record names (not the unit main PID, not daemon.pid)
|
|
129
131
|
];
|
|
130
132
|
/**
|
|
131
133
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
@@ -174,6 +176,12 @@ export const FINDING_SEVERITY = {
|
|
|
174
176
|
'ssh-key-enrollment': 'critical',
|
|
175
177
|
'stale-cli': 'warning',
|
|
176
178
|
'binary-shadow': 'warning',
|
|
179
|
+
// A stray `__daemon-run` under its own (usually temp) HOME: it shares nothing
|
|
180
|
+
// with this install's state, so it cannot double-fire the real box's work —
|
|
181
|
+
// but it is a leak nothing owns and it outlives whatever launched it (W4,
|
|
182
|
+
// PHNX-3736). A human reads its HOME/start time and kills it; nothing is
|
|
183
|
+
// blocked right now.
|
|
184
|
+
'leaked-daemon': 'warning',
|
|
177
185
|
};
|
|
178
186
|
function agentName(agent) {
|
|
179
187
|
return AGENT_NAMES[agent] || agent;
|
|
@@ -305,6 +313,11 @@ export function remediationFor(finding) {
|
|
|
305
313
|
return 'upgrade';
|
|
306
314
|
case 'binary-shadow':
|
|
307
315
|
return 'remove or repoint the shadowing agents install(s)';
|
|
316
|
+
case 'leaked-daemon':
|
|
317
|
+
// The builder emits the concrete `kill <pid>` — the pid is a property of
|
|
318
|
+
// the row it builds, not of anything remediationFor can see. This is the
|
|
319
|
+
// shape only, for a kind constructed through finding() by a future caller.
|
|
320
|
+
return 'kill <pid>';
|
|
308
321
|
case 'owner-sink-unreachable':
|
|
309
322
|
// The lane delivers over the rush-backed owner channel, which needs rush on
|
|
310
323
|
// PATH AND a usable session in THIS context. Non-interactive shells miss a
|
|
@@ -579,6 +592,24 @@ export function buildLocalFindings(input) {
|
|
|
579
592
|
: `${shadows.length} agents binaries may shadow the running copy (incl. ${examples})`,
|
|
580
593
|
}));
|
|
581
594
|
}
|
|
595
|
+
// Leaked daemons (warning) — a `__daemon-run` no owner record names. One row
|
|
596
|
+
// per pid: the remediation is pid-specific, and the HOME + start time are
|
|
597
|
+
// what let the operator tell a leaked test fixture from something they meant
|
|
598
|
+
// to run before they kill it (W4, PHNX-3736). Built directly, not through
|
|
599
|
+
// finding(): the remediation carries this row's own pid.
|
|
600
|
+
for (const d of input.leakedDaemons ?? []) {
|
|
601
|
+
const home = d.home ?? 'unknown HOME';
|
|
602
|
+
const started = d.startedAt ? `, started ${d.startedAt}` : '';
|
|
603
|
+
// The launch entry names WHICH install leaked the daemon — part of what
|
|
604
|
+
// the operator judges before killing. Only a real path: for a stand-in
|
|
605
|
+
// (`node -e '<code>' __daemon-run`) the token is the code blob.
|
|
606
|
+
const entry = d.entry && path.isAbsolute(d.entry) ? ` · ${d.entry}` : '';
|
|
607
|
+
out.push({
|
|
608
|
+
severity: FINDING_SEVERITY['leaked-daemon'], kind: 'leaked-daemon', device,
|
|
609
|
+
message: `stray agents daemon (pid ${d.pid}) no unit or pid file owns — HOME=${home}${started}${entry}`,
|
|
610
|
+
remediation: `kill ${d.pid}`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
582
613
|
return collapseAcrossVersions(out, new Set(input.isolatedVersions ?? []));
|
|
583
614
|
}
|
|
584
615
|
/**
|
|
@@ -2256,12 +2256,72 @@ export function pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, rem
|
|
|
2256
2256
|
}
|
|
2257
2257
|
return removed;
|
|
2258
2258
|
}
|
|
2259
|
+
function trustCodexHooks(hooksPath) {
|
|
2260
|
+
const configPath = path.join(path.dirname(hooksPath), 'config.toml');
|
|
2261
|
+
const hooksFile = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
2262
|
+
let tomlConfig = {};
|
|
2263
|
+
if (fs.existsSync(configPath)) {
|
|
2264
|
+
tomlConfig = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
2265
|
+
}
|
|
2266
|
+
if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
|
|
2267
|
+
tomlConfig.features = {};
|
|
2268
|
+
}
|
|
2269
|
+
// Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
|
|
2270
|
+
// an unrecognized key that triggers a deprecation error and is ignored).
|
|
2271
|
+
const features = tomlConfig.features;
|
|
2272
|
+
delete features.codex_hooks;
|
|
2273
|
+
features.hooks = true;
|
|
2274
|
+
// Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
|
|
2275
|
+
// exactly as Codex resolves it (the absolute CODEX_HOME path), the
|
|
2276
|
+
// snake_case event label, and the per-event group/handler indices — which
|
|
2277
|
+
// must match Codex's parse order, so we iterate the just-written
|
|
2278
|
+
// hooksFile structure in array order.
|
|
2279
|
+
if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
|
|
2280
|
+
tomlConfig.hooks = {};
|
|
2281
|
+
}
|
|
2282
|
+
const hooksTable = tomlConfig.hooks;
|
|
2283
|
+
const existingState = hooksTable.state && typeof hooksTable.state === 'object'
|
|
2284
|
+
? hooksTable.state
|
|
2285
|
+
: {};
|
|
2286
|
+
const hookState = {};
|
|
2287
|
+
for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
|
|
2288
|
+
const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
|
|
2289
|
+
if (!eventKeyLabel)
|
|
2290
|
+
continue;
|
|
2291
|
+
eventGroups.forEach((group, groupIdx) => {
|
|
2292
|
+
if (!group.hooks)
|
|
2293
|
+
return;
|
|
2294
|
+
group.hooks.forEach((handler, handlerIdx) => {
|
|
2295
|
+
if (handler.type !== 'command')
|
|
2296
|
+
return;
|
|
2297
|
+
const key = `${hooksPath}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`;
|
|
2298
|
+
const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
|
|
2299
|
+
// Preserve a user's explicit `enabled = false` for this exact hook;
|
|
2300
|
+
// only (re)write the trust hash.
|
|
2301
|
+
const prior = existingState[key];
|
|
2302
|
+
const entry = { trusted_hash: trustedHash };
|
|
2303
|
+
if (prior && prior.enabled === false) {
|
|
2304
|
+
entry.enabled = false;
|
|
2305
|
+
}
|
|
2306
|
+
hookState[key] = entry;
|
|
2307
|
+
});
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
// Carry forward trust state for any hooks we did not (re)register this
|
|
2311
|
+
// pass — e.g. user-added hooks under a different command path.
|
|
2312
|
+
for (const [key, entry] of Object.entries(existingState)) {
|
|
2313
|
+
if (!(key in hookState)) {
|
|
2314
|
+
hookState[key] = entry;
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
hooksTable.state = hookState;
|
|
2318
|
+
fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
|
|
2319
|
+
}
|
|
2259
2320
|
function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
|
|
2260
2321
|
const registered = [];
|
|
2261
2322
|
const errors = [];
|
|
2262
2323
|
const configDir = path.join(versionHome, '.codex');
|
|
2263
2324
|
const hooksPath = path.join(configDir, 'hooks.json');
|
|
2264
|
-
const configPath = path.join(configDir, 'config.toml');
|
|
2265
2325
|
// Read existing hooks.json — must have top-level "hooks" wrapper key
|
|
2266
2326
|
let hooksFile = { hooks: {} };
|
|
2267
2327
|
if (fs.existsSync(hooksPath)) {
|
|
@@ -2373,66 +2433,7 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
|
|
|
2373
2433
|
// them, so an untrusted hook is silently dropped. We compute the same
|
|
2374
2434
|
// trust hash Codex would and persist it under [hooks.state].
|
|
2375
2435
|
try {
|
|
2376
|
-
|
|
2377
|
-
if (fs.existsSync(configPath)) {
|
|
2378
|
-
try {
|
|
2379
|
-
tomlConfig = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
2380
|
-
}
|
|
2381
|
-
catch { /* start fresh if corrupt */ }
|
|
2382
|
-
}
|
|
2383
|
-
if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
|
|
2384
|
-
tomlConfig.features = {};
|
|
2385
|
-
}
|
|
2386
|
-
// Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
|
|
2387
|
-
// an unrecognized key that triggers a deprecation error and is ignored).
|
|
2388
|
-
const features = tomlConfig.features;
|
|
2389
|
-
delete features.codex_hooks;
|
|
2390
|
-
features.hooks = true;
|
|
2391
|
-
// Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
|
|
2392
|
-
// exactly as Codex resolves it (the absolute CODEX_HOME path), the
|
|
2393
|
-
// snake_case event label, and the per-event group/handler indices — which
|
|
2394
|
-
// must match Codex's parse order, so we iterate the just-written
|
|
2395
|
-
// hooksFile structure in array order.
|
|
2396
|
-
if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
|
|
2397
|
-
tomlConfig.hooks = {};
|
|
2398
|
-
}
|
|
2399
|
-
const hooksTable = tomlConfig.hooks;
|
|
2400
|
-
const existingState = hooksTable.state && typeof hooksTable.state === 'object'
|
|
2401
|
-
? hooksTable.state
|
|
2402
|
-
: {};
|
|
2403
|
-
const hookState = {};
|
|
2404
|
-
for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
|
|
2405
|
-
const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
|
|
2406
|
-
if (!eventKeyLabel)
|
|
2407
|
-
continue;
|
|
2408
|
-
eventGroups.forEach((group, groupIdx) => {
|
|
2409
|
-
if (!group.hooks)
|
|
2410
|
-
return;
|
|
2411
|
-
group.hooks.forEach((handler, handlerIdx) => {
|
|
2412
|
-
if (handler.type !== 'command')
|
|
2413
|
-
return;
|
|
2414
|
-
const key = `${hooksPath}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`;
|
|
2415
|
-
const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
|
|
2416
|
-
// Preserve a user's explicit `enabled = false` for this exact hook;
|
|
2417
|
-
// only (re)write the trust hash.
|
|
2418
|
-
const prior = existingState[key];
|
|
2419
|
-
const entry = { trusted_hash: trustedHash };
|
|
2420
|
-
if (prior && prior.enabled === false) {
|
|
2421
|
-
entry.enabled = false;
|
|
2422
|
-
}
|
|
2423
|
-
hookState[key] = entry;
|
|
2424
|
-
});
|
|
2425
|
-
});
|
|
2426
|
-
}
|
|
2427
|
-
// Carry forward trust state for any hooks we did not (re)register this
|
|
2428
|
-
// pass — e.g. user-added hooks under a different command path.
|
|
2429
|
-
for (const [key, entry] of Object.entries(existingState)) {
|
|
2430
|
-
if (!(key in hookState)) {
|
|
2431
|
-
hookState[key] = entry;
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
hooksTable.state = hookState;
|
|
2435
|
-
fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
|
|
2436
|
+
trustCodexHooks(hooksPath);
|
|
2436
2437
|
}
|
|
2437
2438
|
catch (err) {
|
|
2438
2439
|
errors.push(`Failed to update config.toml: ${err.message}`);
|
|
@@ -3282,11 +3283,14 @@ export async function installSessionTrackerHook(agent, version, home) {
|
|
|
3282
3283
|
if (!invocation) {
|
|
3283
3284
|
return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
|
|
3284
3285
|
}
|
|
3286
|
+
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3285
3287
|
try {
|
|
3286
3288
|
await execFileAsync(invocation.command, invocation.args, {
|
|
3287
|
-
env
|
|
3289
|
+
env,
|
|
3288
3290
|
encoding: 'utf8',
|
|
3289
3291
|
});
|
|
3292
|
+
if (agent === 'codex')
|
|
3293
|
+
trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
|
|
3290
3294
|
return { installed: true };
|
|
3291
3295
|
}
|
|
3292
3296
|
catch (err) {
|
|
@@ -3324,12 +3328,15 @@ export function installSessionTrackerHookSync(agent, version, home) {
|
|
|
3324
3328
|
if (!invocation) {
|
|
3325
3329
|
return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
|
|
3326
3330
|
}
|
|
3331
|
+
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3327
3332
|
try {
|
|
3328
3333
|
execFileSync(invocation.command, invocation.args, {
|
|
3329
|
-
env
|
|
3334
|
+
env,
|
|
3330
3335
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3331
3336
|
encoding: 'utf8',
|
|
3332
3337
|
});
|
|
3338
|
+
if (agent === 'codex')
|
|
3339
|
+
trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
|
|
3333
3340
|
return { installed: true };
|
|
3334
3341
|
}
|
|
3335
3342
|
catch (err) {
|
|
@@ -182,7 +182,7 @@ export interface InteractiveDispatchOptions {
|
|
|
182
182
|
agent: string;
|
|
183
183
|
/** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
|
|
184
184
|
version?: string;
|
|
185
|
-
/** Preserve the trailing
|
|
185
|
+
/** Preserve the trailing-# account picker so selection happens on the execution host. */
|
|
186
186
|
accountPicker?: boolean;
|
|
187
187
|
/** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
|
|
188
188
|
strategy?: string;
|
|
@@ -362,7 +362,7 @@ async function launchDetached(host, target, opts) {
|
|
|
362
362
|
/** Compose `agent[@version][#account]` so the peer resolves ITS slot (PHNX-3940 T5). */
|
|
363
363
|
export function runAgentSpecArg(opts) {
|
|
364
364
|
if (opts.accountPicker)
|
|
365
|
-
return `${opts.agent}
|
|
365
|
+
return `${opts.agent}#`;
|
|
366
366
|
let spec = opts.agent;
|
|
367
367
|
if (opts.version)
|
|
368
368
|
spec += `@${opts.version}`;
|
package/dist/lib/models.js
CHANGED
|
@@ -13,7 +13,8 @@ import { execFileSync } from 'child_process';
|
|
|
13
13
|
import chalk from 'chalk';
|
|
14
14
|
import { getVersionDir, getVersionHomePath, getBinaryPath } from './installations/versions.js';
|
|
15
15
|
import { getModelsCachePath } from './state.js';
|
|
16
|
-
import { agentConfigDirName } from './agents.js';
|
|
16
|
+
import { agentConfigDirName, resolveOpenCodeXdgPath } from './agents.js';
|
|
17
|
+
import { stripJsonComments } from './permissions-registry.js';
|
|
17
18
|
import { resolveRunDefaults } from './run-defaults.js';
|
|
18
19
|
import { getModelPricing } from './pricing/index.js';
|
|
19
20
|
const CACHE_PATH = getModelsCachePath();
|
|
@@ -1014,6 +1015,12 @@ export function resolveConfiguredModel(agent, version, home) {
|
|
|
1014
1015
|
const nativeModel = readNativeConfigModel(agent, version, home);
|
|
1015
1016
|
if (nativeModel)
|
|
1016
1017
|
return { model: nativeModel, source: 'config' };
|
|
1018
|
+
// The agent's own persisted selection, for a runtime that stores one instead
|
|
1019
|
+
// of flagging a catalog default. Ranks below `config` (an explicit setting
|
|
1020
|
+
// wins) and above the catalog, whose `isDefault` OpenCode never sets.
|
|
1021
|
+
const selected = readNativeSelectedModel(agent, version);
|
|
1022
|
+
if (selected)
|
|
1023
|
+
return { model: selected, source: 'cli-default' };
|
|
1017
1024
|
const catalog = getModelCatalog(agent, version);
|
|
1018
1025
|
if (catalog) {
|
|
1019
1026
|
const flagged = catalog.models.find((m) => m.isDefault);
|
|
@@ -1022,15 +1029,79 @@ export function resolveConfiguredModel(agent, version, home) {
|
|
|
1022
1029
|
return null;
|
|
1023
1030
|
}
|
|
1024
1031
|
/**
|
|
1025
|
-
*
|
|
1032
|
+
* Where each agent keeps its OWN `model` setting, when that is not the
|
|
1033
|
+
* `<configDir>/settings.json` every Claude-shaped harness uses.
|
|
1034
|
+
*
|
|
1035
|
+
* OpenCode reads `~/.config/opencode/opencode.{jsonc,json}` and puts `model` at
|
|
1036
|
+
* its top level as `"<provider>/<model-id>"`. Its `~/.opencode/settings.json`
|
|
1037
|
+
* DOES exist, but that is agents-cli's own plugin-enablement file, which
|
|
1038
|
+
* OpenCode never reads a model from — so the default path found nothing and
|
|
1039
|
+
* every OpenCode row rendered the placeholder `default`. Comments are stripped
|
|
1040
|
+
* for BOTH spellings because OpenCode's loader accepts them in both, which is
|
|
1041
|
+
* also how the MCP writer treats the same file (`format: 'opencode-jsonc'`).
|
|
1042
|
+
*/
|
|
1043
|
+
const NATIVE_MODEL_CONFIGS = {
|
|
1044
|
+
opencode: {
|
|
1045
|
+
paths: (home) => [
|
|
1046
|
+
path.join(home, '.config', 'opencode', 'opencode.jsonc'),
|
|
1047
|
+
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
1048
|
+
],
|
|
1049
|
+
jsonc: true,
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
/**
|
|
1053
|
+
* Best-effort read of the agent's own `model` from its native config
|
|
1026
1054
|
* (e.g. `~/.agents/.history/versions/claude/<ver>/home/.claude/settings.json`).
|
|
1027
1055
|
* A missing/malformed file is a fall-through, not an error.
|
|
1028
1056
|
*/
|
|
1029
1057
|
function readNativeConfigModel(agent, version, home) {
|
|
1058
|
+
const resolvedHome = home ?? getVersionHomePath(agent, version);
|
|
1059
|
+
const native = NATIVE_MODEL_CONFIGS[agent];
|
|
1060
|
+
const candidates = native?.paths(resolvedHome)
|
|
1061
|
+
?? [path.join(resolvedHome, agentConfigDirName(agent), 'settings.json')];
|
|
1062
|
+
for (const configPath of candidates) {
|
|
1063
|
+
try {
|
|
1064
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
1065
|
+
const parsed = JSON.parse(native?.jsonc ? stripJsonComments(raw) : raw);
|
|
1066
|
+
if (typeof parsed.model === 'string' && parsed.model.trim() !== '')
|
|
1067
|
+
return parsed.model;
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
/* absent or malformed — try the next spelling */
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* The model the agent's OWN runtime will start with, for a harness that persists
|
|
1077
|
+
* its selection rather than flagging a catalog default.
|
|
1078
|
+
*
|
|
1079
|
+
* OpenCode is the case that needs it: it ships no default model, so its catalog
|
|
1080
|
+
* has no `isDefault` and `agents view` fell through to the literal `default` —
|
|
1081
|
+
* a placeholder, not something the user could act on. OpenCode instead records
|
|
1082
|
+
* the model picked in its TUI to `$XDG_STATE_HOME/opencode/model.json` as
|
|
1083
|
+
* `{ recent: [{ providerID, modelID }, …] }`, newest first, and reuses
|
|
1084
|
+
* `recent[0]` for the next session. That entry is the real answer to "what model
|
|
1085
|
+
* is this install on", rendered the same `<provider>/<model-id>` way OpenCode's
|
|
1086
|
+
* own config spells it.
|
|
1087
|
+
*
|
|
1088
|
+
* Returns null for every other agent, and for OpenCode when nothing has been
|
|
1089
|
+
* selected yet — the catalog fallback still applies.
|
|
1090
|
+
*/
|
|
1091
|
+
function readNativeSelectedModel(agent, version) {
|
|
1092
|
+
if (agent !== 'opencode')
|
|
1093
|
+
return null;
|
|
1094
|
+
const statePath = resolveOpenCodeXdgPath(getVersionHomePath(agent, version), 'state', 'model.json');
|
|
1095
|
+
if (!statePath)
|
|
1096
|
+
return null;
|
|
1030
1097
|
try {
|
|
1031
|
-
const
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1098
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
1099
|
+
const current = parsed.recent?.[0];
|
|
1100
|
+
const provider = typeof current?.providerID === 'string' ? current.providerID : '';
|
|
1101
|
+
const model = typeof current?.modelID === 'string' ? current.modelID : '';
|
|
1102
|
+
if (!model)
|
|
1103
|
+
return null;
|
|
1104
|
+
return provider ? `${provider}/${model}` : model;
|
|
1034
1105
|
}
|
|
1035
1106
|
catch {
|
|
1036
1107
|
return null;
|
|
@@ -53,6 +53,8 @@ function agentToFormat(agent) {
|
|
|
53
53
|
return 'rush';
|
|
54
54
|
if (agent === 'droid')
|
|
55
55
|
return 'droid';
|
|
56
|
+
if (agent === 'opencode')
|
|
57
|
+
return 'opencode';
|
|
56
58
|
return null;
|
|
57
59
|
}
|
|
58
60
|
export function assertContained(candidate, rootDir) {
|
|
@@ -128,7 +130,7 @@ export async function ensureCloudSessionCached(executionId, destPath) {
|
|
|
128
130
|
throw new Error(`session.jsonl fetch ${res.status}: ${body.slice(0, 200)}`);
|
|
129
131
|
}
|
|
130
132
|
const format = (res.headers.get('X-Session-Format') || '').toLowerCase();
|
|
131
|
-
if (!['claude', 'codex', 'rush'].includes(format)) {
|
|
133
|
+
if (!['claude', 'codex', 'rush', 'opencode'].includes(format)) {
|
|
132
134
|
throw new Error(`Unknown X-Session-Format on cloud response: "${format}"`);
|
|
133
135
|
}
|
|
134
136
|
const finalPath = callerPath ?? cachePathForExecution(id, format);
|
|
@@ -229,6 +229,7 @@ export declare function parseGrok(filePath: string): SessionEvent[];
|
|
|
229
229
|
*/
|
|
230
230
|
export declare const OPENCODE_TRANSCRIPT_QUERY: string;
|
|
231
231
|
export declare function parseOpenCode(filePath: string): SessionEvent[];
|
|
232
|
+
export declare function parseOpencodeCloud(filePath: string): SessionEvent[];
|
|
232
233
|
/** Parse a Rush JSONL session file into normalized events. */
|
|
233
234
|
export declare function parseRush(filePath: string): SessionEvent[];
|
|
234
235
|
/**
|
|
@@ -12,6 +12,7 @@ import * as path from 'path';
|
|
|
12
12
|
import Database from '../sqlite.js';
|
|
13
13
|
import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput, unwrapUserQuery } from './prompt.js';
|
|
14
14
|
import { structuredToolResult, commandsFromCodexExec } from './tool-calls.js';
|
|
15
|
+
import { isCloudSessionPath } from './cloud.js';
|
|
15
16
|
/**
|
|
16
17
|
* Largest session file we will load into memory. Above this we throw a clean
|
|
17
18
|
* error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
|
|
@@ -143,7 +144,11 @@ const TRANSCRIPT_PARSERS = {
|
|
|
143
144
|
codex: (filePath) => parseCodex(filePath),
|
|
144
145
|
gemini: (filePath) => parseGemini(filePath),
|
|
145
146
|
antigravity: (filePath) => parseAntigravity(filePath),
|
|
146
|
-
opencode
|
|
147
|
+
// Cloud-captured opencode sessions are normalized JSONL (produced by the
|
|
148
|
+
// factory at capture time), NOT the local `opencode.db#<session>` SQLite
|
|
149
|
+
// composite the offline parser reads — route by whether the path is in the
|
|
150
|
+
// cloud session cache (PHNX-3845).
|
|
151
|
+
opencode: (filePath) => isCloudSessionPath(filePath) ? parseOpencodeCloud(filePath) : parseOpenCode(filePath),
|
|
147
152
|
grok: (filePath) => parseGrok(filePath),
|
|
148
153
|
rush: (filePath) => parseRush(filePath),
|
|
149
154
|
openclaw: () => [], // OpenClaw sessions don't have parseable files yet
|
|
@@ -206,7 +211,7 @@ export function detectAgent(filePath) {
|
|
|
206
211
|
return 'muse';
|
|
207
212
|
}
|
|
208
213
|
// Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
|
|
209
|
-
const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
|
|
214
|
+
const cloudMatch = filePath.match(/session\.(claude|codex|rush|opencode)\.jsonl(?:$|[?#])/);
|
|
210
215
|
if (cloudMatch)
|
|
211
216
|
return cloudMatch[1];
|
|
212
217
|
if (filePath.includes('opencode.db'))
|
|
@@ -1832,6 +1837,135 @@ export function parseOpenCode(filePath) {
|
|
|
1832
1837
|
return events;
|
|
1833
1838
|
}
|
|
1834
1839
|
// ---------------------------------------------------------------------------
|
|
1840
|
+
// OpenCode parser — cloud (normalized JSONL)
|
|
1841
|
+
// ---------------------------------------------------------------------------
|
|
1842
|
+
//
|
|
1843
|
+
// OpenCode stores sessions in a SQLite DB, so the offline path reads a
|
|
1844
|
+
// `opencode.db#<session>` composite (parseOpenCode above). The CLOUD path is
|
|
1845
|
+
// different: the factory converts that DB into a flat, normalized JSONL
|
|
1846
|
+
// transcript at capture time (prix/factory opencode-capture.ts, PHNX-3845), so
|
|
1847
|
+
// the whole cloud pipeline stays SQLite-free downstream. This parser reads that
|
|
1848
|
+
// JSONL; it's routed from TRANSCRIPT_PARSERS only when the file lives in the
|
|
1849
|
+
// cloud session cache (isCloudSessionPath).
|
|
1850
|
+
//
|
|
1851
|
+
// Each line is one of:
|
|
1852
|
+
// transcript row:
|
|
1853
|
+
// { role: "user"|"assistant", part_type: "text"|"reasoning"|"tool",
|
|
1854
|
+
// part_data: <json string>, time_created: <ms number> }
|
|
1855
|
+
// todo snapshot (at most one, emitted last):
|
|
1856
|
+
// { part_type: "todo", todos: [{content,status}], time_created: <ms> }
|
|
1857
|
+
//
|
|
1858
|
+
// The per-part logic mirrors parseOpenCode's post-query switch (and prix/api's
|
|
1859
|
+
// server-side parseOpencode) so the event stream is identical to the offline
|
|
1860
|
+
// path. NOTE: the shell tool is named `bash` on real opencode (>=1.18.x), not
|
|
1861
|
+
// `shell` — map `command` for both so a shell step reads by its command line.
|
|
1862
|
+
export function parseOpencodeCloud(filePath) {
|
|
1863
|
+
const content = safeReadSessionFile(filePath);
|
|
1864
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
1865
|
+
const events = [];
|
|
1866
|
+
for (const line of lines) {
|
|
1867
|
+
let raw;
|
|
1868
|
+
try {
|
|
1869
|
+
raw = JSON.parse(line);
|
|
1870
|
+
}
|
|
1871
|
+
catch {
|
|
1872
|
+
/* malformed JSONL line, skip */
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
const partType = typeof raw.part_type === 'string' ? raw.part_type : '';
|
|
1876
|
+
const timeMs = typeof raw.time_created === 'number'
|
|
1877
|
+
? raw.time_created
|
|
1878
|
+
: parseInt(String(raw.time_created), 10);
|
|
1879
|
+
const timestamp = Number.isFinite(timeMs)
|
|
1880
|
+
? new Date(timeMs).toISOString()
|
|
1881
|
+
: new Date().toISOString();
|
|
1882
|
+
// Todo snapshot: emit one `todo_write` tool_use so the shared enrichment
|
|
1883
|
+
// (`extractTodoProgressFromEvents`) derives `todos` the same way it does for
|
|
1884
|
+
// every other harness — the offline parser emits the identical event.
|
|
1885
|
+
if (partType === 'todo') {
|
|
1886
|
+
const todos = Array.isArray(raw.todos) ? raw.todos : [];
|
|
1887
|
+
if (todos.length) {
|
|
1888
|
+
events.push({
|
|
1889
|
+
type: 'tool_use',
|
|
1890
|
+
agent: 'opencode',
|
|
1891
|
+
timestamp,
|
|
1892
|
+
tool: 'todo_write',
|
|
1893
|
+
args: { todos },
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
let partData;
|
|
1899
|
+
try {
|
|
1900
|
+
partData = JSON.parse(typeof raw.part_data === 'string' ? raw.part_data : '');
|
|
1901
|
+
}
|
|
1902
|
+
catch {
|
|
1903
|
+
/* malformed part data, skip */
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
switch (partType) {
|
|
1907
|
+
case 'text': {
|
|
1908
|
+
const text = (partData.text || '').trim();
|
|
1909
|
+
if (text) {
|
|
1910
|
+
events.push({
|
|
1911
|
+
type: 'message',
|
|
1912
|
+
agent: 'opencode',
|
|
1913
|
+
timestamp,
|
|
1914
|
+
role: raw.role === 'user' ? 'user' : 'assistant',
|
|
1915
|
+
content: text,
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
break;
|
|
1919
|
+
}
|
|
1920
|
+
case 'reasoning': {
|
|
1921
|
+
const text = (partData.text || '').trim();
|
|
1922
|
+
if (text) {
|
|
1923
|
+
events.push({
|
|
1924
|
+
type: 'thinking',
|
|
1925
|
+
agent: 'opencode',
|
|
1926
|
+
timestamp,
|
|
1927
|
+
content: text,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
break;
|
|
1931
|
+
}
|
|
1932
|
+
case 'tool': {
|
|
1933
|
+
const toolName = partData.tool || 'unknown';
|
|
1934
|
+
const state = partData.state || {};
|
|
1935
|
+
const input = state.input || {};
|
|
1936
|
+
const output = state.output || '';
|
|
1937
|
+
const callId = typeof partData.callID === 'string' ? partData.callID : undefined;
|
|
1938
|
+
events.push({
|
|
1939
|
+
type: 'tool_use',
|
|
1940
|
+
agent: 'opencode',
|
|
1941
|
+
timestamp,
|
|
1942
|
+
tool: toolName,
|
|
1943
|
+
callId,
|
|
1944
|
+
args: input,
|
|
1945
|
+
command: toolName === 'bash' || toolName === 'shell' ? input.command : undefined,
|
|
1946
|
+
path: input.filePath || input.path || undefined,
|
|
1947
|
+
});
|
|
1948
|
+
if (state.status === 'completed' || state.status === 'error') {
|
|
1949
|
+
const outputStr = typeof output === 'string' ? output : JSON.stringify(output);
|
|
1950
|
+
events.push({
|
|
1951
|
+
type: state.status === 'error' ? 'error' : 'tool_result',
|
|
1952
|
+
agent: 'opencode',
|
|
1953
|
+
timestamp,
|
|
1954
|
+
tool: toolName,
|
|
1955
|
+
callId,
|
|
1956
|
+
success: state.status === 'completed',
|
|
1957
|
+
output: outputStr,
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
break;
|
|
1961
|
+
}
|
|
1962
|
+
// step-start / step-finish / patch / file never reach the JSONL (dropped
|
|
1963
|
+
// at capture time) — nothing to handle here.
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
return events;
|
|
1967
|
+
}
|
|
1968
|
+
// ---------------------------------------------------------------------------
|
|
1835
1969
|
// Rush parser
|
|
1836
1970
|
//
|
|
1837
1971
|
// Rush messages.jsonl format is flat: one JSON object per line with
|
|
@@ -68,7 +68,15 @@ export declare function sessionMatchesAccount(session: Pick<SessionMeta, 'agent'
|
|
|
68
68
|
export declare function inspectNativeResumeSession(session: SessionMeta, versionHome: string): NativeResumeInspection;
|
|
69
69
|
/** Keep the native conversation where possible; replay remains a separate choice. */
|
|
70
70
|
export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection, options?: SessionRecoverySelection): SessionRecoveryTarget;
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Whether recovery has conversation content to replay for this session: a
|
|
73
|
+
* non-empty transcript file, or archived content in the index for a row this
|
|
74
|
+
* device owns. A mirror digest or live registry entry is not conversation
|
|
75
|
+
* content. The picker consults this before spending a terminal tab on a pick
|
|
76
|
+
* that {@link assertRecoverableTranscript} would refuse one hop later.
|
|
77
|
+
*/
|
|
78
|
+
export declare function sessionTranscriptReadable(session: SessionMeta): boolean;
|
|
79
|
+
/** Refuse recovery for a session with nothing to replay (PHNX-4080). */
|
|
72
80
|
export declare function assertRecoverableTranscript(session: SessionMeta): void;
|
|
73
81
|
/**
|
|
74
82
|
* Resolve recovery for a durable session, reading the live account pool.
|
|
@@ -365,16 +365,26 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
365
365
|
reason: `${sourceReason(session, source, options.model ?? session.model)}; continuing with ${continueWith}`,
|
|
366
366
|
};
|
|
367
367
|
}
|
|
368
|
-
/**
|
|
369
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Whether recovery has conversation content to replay for this session: a
|
|
370
|
+
* non-empty transcript file, or archived content in the index for a row this
|
|
371
|
+
* device owns. A mirror digest or live registry entry is not conversation
|
|
372
|
+
* content. The picker consults this before spending a terminal tab on a pick
|
|
373
|
+
* that {@link assertRecoverableTranscript} would refuse one hop later.
|
|
374
|
+
*/
|
|
375
|
+
export function sessionTranscriptReadable(session) {
|
|
370
376
|
const file = splitSessionFilePath(session.filePath).container;
|
|
371
377
|
try {
|
|
372
378
|
if (file && fs.statSync(file).isFile() && fs.statSync(file).size > 0
|
|
373
379
|
&& (session.agent !== 'opencode' || parseOpenCode(session.filePath).length > 0))
|
|
374
|
-
return;
|
|
380
|
+
return true;
|
|
375
381
|
}
|
|
376
382
|
catch { /* The canonical scan already tried to repair this path. */ }
|
|
377
|
-
|
|
383
|
+
return !session.mirrorSyncedAt && !session.mirrorSource && Boolean(readSessionContent(session.id)?.trim());
|
|
384
|
+
}
|
|
385
|
+
/** Refuse recovery for a session with nothing to replay (PHNX-4080). */
|
|
386
|
+
export function assertRecoverableTranscript(session) {
|
|
387
|
+
if (sessionTranscriptReadable(session))
|
|
378
388
|
return;
|
|
379
389
|
throw new SessionRecoveryError(`Session ${session.shortId} has no readable transcript after checking its account homes and index. ` +
|
|
380
390
|
`No agent was started. Start a new conversation with: agents run ${session.agent}`);
|