@phnx-labs/agents-cli 1.22.107 → 1.22.110
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 +96 -0
- package/dist/bootstrap.js +3 -2
- package/dist/commands/browser-sessions-picker.js +2 -1
- package/dist/commands/computer-sessions-picker.js +2 -1
- package/dist/commands/cost.js +2 -1
- package/dist/commands/daemon.js +45 -40
- package/dist/commands/doctor.js +7 -0
- package/dist/commands/exec.js +4 -2
- package/dist/commands/fork.js +6 -4
- package/dist/commands/logs.js +2 -1
- package/dist/commands/monitors.js +15 -2
- package/dist/commands/routines.js +46 -6
- package/dist/commands/sessions-backfill.d.ts +30 -0
- package/dist/commands/sessions-backfill.js +72 -0
- package/dist/commands/sessions-backup-setup.d.ts +12 -0
- package/dist/commands/sessions-backup-setup.js +65 -0
- package/dist/commands/sessions-inject.d.ts +3 -3
- package/dist/commands/sessions-inject.js +5 -11
- package/dist/commands/sessions-picker.js +10 -7
- package/dist/commands/sessions-resume.d.ts +17 -2
- package/dist/commands/sessions-resume.js +88 -12
- package/dist/commands/sessions.js +46 -24
- package/dist/commands/share.js +15 -2
- package/dist/commands/sync.js +30 -1
- package/dist/commands/utils.d.ts +9 -0
- package/dist/commands/utils.js +18 -0
- package/dist/commands/watchdog.js +4 -1
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +17 -1
- package/dist/lib/accounting/usage-sync.d.ts +22 -0
- package/dist/lib/accounting/usage-sync.js +70 -6
- package/dist/lib/accounting/usage.d.ts +32 -0
- package/dist/lib/accounting/usage.js +37 -2
- 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/auth-health.js +19 -0
- package/dist/lib/claude-statusline.js +5 -0
- package/dist/lib/computer/sessions-list.js +2 -1
- package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
- package/dist/lib/daemon/auth-sync-service.js +2 -2
- package/dist/lib/daemon/daemon.d.ts +34 -1
- package/dist/lib/daemon/daemon.js +70 -9
- package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
- package/dist/lib/daemon/leaked-daemons.js +180 -0
- package/dist/lib/daemon/session-title-service.d.ts +41 -0
- package/dist/lib/daemon/session-title-service.js +76 -0
- package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
- package/dist/lib/daemon/usage-sync-service.js +10 -2
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/daemon-ticks.js +6 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +32 -1
- package/dist/lib/fleet-shared-state.d.ts +2 -0
- package/dist/lib/hooks/install.js +78 -64
- package/dist/lib/mailbox-target.js +2 -1
- package/dist/lib/models.js +76 -5
- package/dist/lib/session/active.d.ts +93 -16
- package/dist/lib/session/active.js +80 -14
- package/dist/lib/session/cloud.js +3 -1
- package/dist/lib/session/db.d.ts +44 -1
- package/dist/lib/session/db.js +122 -17
- package/dist/lib/session/fork.d.ts +10 -2
- package/dist/lib/session/fork.js +11 -2
- package/dist/lib/session/live-metadata.js +6 -0
- package/dist/lib/session/mirror.d.ts +3 -2
- package/dist/lib/session/mirror.js +5 -2
- 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/remote/watch.js +18 -8
- package/dist/lib/session/title.d.ts +256 -0
- package/dist/lib/session/title.js +312 -0
- package/dist/lib/session/tool-calls.d.ts +1 -1
- package/dist/lib/session/tool-calls.js +40 -5
- package/dist/lib/session/tool-index.d.ts +5 -0
- package/dist/lib/session/tool-index.js +1 -0
- package/dist/lib/session/types.d.ts +11 -0
- 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/startup/root-command.d.ts +2 -0
- package/dist/lib/startup/root-command.js +22 -0
- package/dist/lib/terminal/engine.js +13 -1
- package/dist/lib/traces/sync.js +1 -0
- package/dist/lib/usage-refresh.d.ts +58 -7
- package/dist/lib/usage-refresh.js +145 -23
- package/dist/lib/watchdog/runner.d.ts +3 -0
- package/dist/lib/watchdog/runner.js +1 -0
- package/dist/session-tracker/dist/install-hook.js +4 -4
- package/package.json +1 -1
|
@@ -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
|
/**
|
|
@@ -21,6 +21,8 @@ export interface SessionMirrorRow {
|
|
|
21
21
|
cwd?: string;
|
|
22
22
|
topic?: string;
|
|
23
23
|
label?: string;
|
|
24
|
+
/** The publisher's daemon-generated headline (PHNX-3797), when it has produced one. */
|
|
25
|
+
title?: string;
|
|
24
26
|
firstUser?: string;
|
|
25
27
|
lastActivity?: string;
|
|
26
28
|
timestamp: string;
|
|
@@ -18,8 +18,9 @@ import { promisify } from 'util';
|
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
19
|
import { AGENTS, agentConfigDirName, isAgentHardDeprecated } from '../agents.js';
|
|
20
20
|
import { supports, explainSkip, capableAgents } from '../capabilities.js';
|
|
21
|
-
import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getPerfDir } from '../state.js';
|
|
21
|
+
import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getPerfDir, getHistoryDir } from '../state.js';
|
|
22
22
|
import { collectSubruleHooksFromState } from '../rules/compose.js';
|
|
23
|
+
import { codexShortKey, resolveCodexHome } from '../codex-home.js';
|
|
23
24
|
function getCentralHooksDir() { return getUserHooksDir(); }
|
|
24
25
|
/**
|
|
25
26
|
* Resolve a hook script's absolute path. Checks user dir first, then enabled
|
|
@@ -2256,12 +2257,73 @@ export function pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, rem
|
|
|
2256
2257
|
}
|
|
2257
2258
|
return removed;
|
|
2258
2259
|
}
|
|
2260
|
+
function trustCodexHooks(hooksPath) {
|
|
2261
|
+
const hookPaths = [...new Set([hooksPath, path.join(fs.realpathSync(path.dirname(hooksPath)), 'hooks.json')])];
|
|
2262
|
+
const configPath = path.join(path.dirname(hooksPath), 'config.toml');
|
|
2263
|
+
const hooksFile = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
2264
|
+
let tomlConfig = {};
|
|
2265
|
+
if (fs.existsSync(configPath)) {
|
|
2266
|
+
tomlConfig = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
2267
|
+
}
|
|
2268
|
+
if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
|
|
2269
|
+
tomlConfig.features = {};
|
|
2270
|
+
}
|
|
2271
|
+
// Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
|
|
2272
|
+
// an unrecognized key that triggers a deprecation error and is ignored).
|
|
2273
|
+
const features = tomlConfig.features;
|
|
2274
|
+
delete features.codex_hooks;
|
|
2275
|
+
features.hooks = true;
|
|
2276
|
+
// Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
|
|
2277
|
+
// exactly as Codex resolves it (the absolute CODEX_HOME path), the
|
|
2278
|
+
// snake_case event label, and the per-event group/handler indices — which
|
|
2279
|
+
// must match Codex's parse order, so we iterate the just-written
|
|
2280
|
+
// hooksFile structure in array order.
|
|
2281
|
+
if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
|
|
2282
|
+
tomlConfig.hooks = {};
|
|
2283
|
+
}
|
|
2284
|
+
const hooksTable = tomlConfig.hooks;
|
|
2285
|
+
const existingState = hooksTable.state && typeof hooksTable.state === 'object'
|
|
2286
|
+
? hooksTable.state
|
|
2287
|
+
: {};
|
|
2288
|
+
const hookState = {};
|
|
2289
|
+
for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
|
|
2290
|
+
const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
|
|
2291
|
+
if (!eventKeyLabel)
|
|
2292
|
+
continue;
|
|
2293
|
+
eventGroups.forEach((group, groupIdx) => {
|
|
2294
|
+
if (!group.hooks)
|
|
2295
|
+
return;
|
|
2296
|
+
group.hooks.forEach((handler, handlerIdx) => {
|
|
2297
|
+
if (handler.type !== 'command')
|
|
2298
|
+
return;
|
|
2299
|
+
const keys = hookPaths.map((file) => `${file}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`);
|
|
2300
|
+
const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
|
|
2301
|
+
// Preserve a user's explicit `enabled = false` for this exact hook;
|
|
2302
|
+
// only (re)write the trust hash.
|
|
2303
|
+
const entry = { trusted_hash: trustedHash };
|
|
2304
|
+
if (keys.some((key) => existingState[key]?.enabled === false)) {
|
|
2305
|
+
entry.enabled = false;
|
|
2306
|
+
}
|
|
2307
|
+
for (const key of keys)
|
|
2308
|
+
hookState[key] = entry;
|
|
2309
|
+
});
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
// Carry forward trust state for any hooks we did not (re)register this
|
|
2313
|
+
// pass — e.g. user-added hooks under a different command path.
|
|
2314
|
+
for (const [key, entry] of Object.entries(existingState)) {
|
|
2315
|
+
if (!(key in hookState)) {
|
|
2316
|
+
hookState[key] = entry;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
hooksTable.state = hookState;
|
|
2320
|
+
fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
|
|
2321
|
+
}
|
|
2259
2322
|
function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
|
|
2260
2323
|
const registered = [];
|
|
2261
2324
|
const errors = [];
|
|
2262
2325
|
const configDir = path.join(versionHome, '.codex');
|
|
2263
2326
|
const hooksPath = path.join(configDir, 'hooks.json');
|
|
2264
|
-
const configPath = path.join(configDir, 'config.toml');
|
|
2265
2327
|
// Read existing hooks.json — must have top-level "hooks" wrapper key
|
|
2266
2328
|
let hooksFile = { hooks: {} };
|
|
2267
2329
|
if (fs.existsSync(hooksPath)) {
|
|
@@ -2373,66 +2435,7 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
|
|
|
2373
2435
|
// them, so an untrusted hook is silently dropped. We compute the same
|
|
2374
2436
|
// trust hash Codex would and persist it under [hooks.state].
|
|
2375
2437
|
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');
|
|
2438
|
+
trustCodexHooks(hooksPath);
|
|
2436
2439
|
}
|
|
2437
2440
|
catch (err) {
|
|
2438
2441
|
errors.push(`Failed to update config.toml: ${err.message}`);
|
|
@@ -3283,10 +3286,13 @@ export async function installSessionTrackerHook(agent, version, home) {
|
|
|
3283
3286
|
return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
|
|
3284
3287
|
}
|
|
3285
3288
|
try {
|
|
3289
|
+
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3286
3290
|
await execFileAsync(invocation.command, invocation.args, {
|
|
3287
|
-
env
|
|
3291
|
+
env,
|
|
3288
3292
|
encoding: 'utf8',
|
|
3289
3293
|
});
|
|
3294
|
+
if (agent === 'codex')
|
|
3295
|
+
trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
|
|
3290
3296
|
return { installed: true };
|
|
3291
3297
|
}
|
|
3292
3298
|
catch (err) {
|
|
@@ -3325,11 +3331,14 @@ export function installSessionTrackerHookSync(agent, version, home) {
|
|
|
3325
3331
|
return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
|
|
3326
3332
|
}
|
|
3327
3333
|
try {
|
|
3334
|
+
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3328
3335
|
execFileSync(invocation.command, invocation.args, {
|
|
3329
|
-
env
|
|
3336
|
+
env,
|
|
3330
3337
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3331
3338
|
encoding: 'utf8',
|
|
3332
3339
|
});
|
|
3340
|
+
if (agent === 'codex')
|
|
3341
|
+
trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
|
|
3333
3342
|
return { installed: true };
|
|
3334
3343
|
}
|
|
3335
3344
|
catch (err) {
|
|
@@ -3339,5 +3348,10 @@ export function installSessionTrackerHookSync(agent, version, home) {
|
|
|
3339
3348
|
/** The hook installer must target this account, not the current global home. */
|
|
3340
3349
|
function sessionTrackerInstallEnv(agent, version, home) {
|
|
3341
3350
|
const target = home ?? (version ? getVersionHomePath(agent, version) : undefined);
|
|
3351
|
+
if (agent === 'codex' && target && version) {
|
|
3352
|
+
const originHome = path.join(target, '.codex');
|
|
3353
|
+
const historyDir = getHistoryDir();
|
|
3354
|
+
resolveCodexHome(originHome, path.dirname(historyDir), codexShortKey(originHome, version, historyDir));
|
|
3355
|
+
}
|
|
3342
3356
|
return target ? { ...process.env, HOME: target, USERPROFILE: target } : { ...process.env };
|
|
3343
3357
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionHeadline } from './session/title.js';
|
|
1
2
|
/**
|
|
2
3
|
* The mailbox id a live session's box is keyed by. Teams stamp a durable
|
|
3
4
|
* `agentId` (== the Claude session id for Claude teammates); a bare run has
|
|
@@ -8,7 +9,7 @@ export function mailboxIdForActiveSession(s) {
|
|
|
8
9
|
return s.agentId ?? s.sessionId;
|
|
9
10
|
}
|
|
10
11
|
function labelFor(s) {
|
|
11
|
-
return s
|
|
12
|
+
return sessionHeadline(s) ?? s.teamName ?? s.host ?? s.context;
|
|
12
13
|
}
|
|
13
14
|
/**
|
|
14
15
|
* Resolve `target` against the live sessions. Exact id matches win over prefix
|
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;
|
|
@@ -55,7 +55,15 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
|
|
|
55
55
|
export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
|
|
56
56
|
type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
|
|
57
57
|
/** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
|
|
58
|
-
export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
|
|
58
|
+
export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'generatedTitle' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
|
|
59
|
+
/**
|
|
60
|
+
* Fold index-only enrichment onto live rows, then RE-DERIVE the recap for every
|
|
61
|
+
* row it touched. The re-derive is load-bearing: `foldRecap` runs inside
|
|
62
|
+
* {@link getActiveSessions}, before any caller reaches the index, so a `label`
|
|
63
|
+
* or `generatedTitle` that only the index knows would otherwise never reach the
|
|
64
|
+
* shown `title` (PHNX-3797). Doing it here rather than at each of the three
|
|
65
|
+
* backfill call sites is what keeps them from drifting apart.
|
|
66
|
+
*/
|
|
59
67
|
export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
|
|
60
68
|
export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
|
|
61
69
|
export declare function isRunningLiveSession(s: ActiveSession): boolean;
|
|
@@ -108,13 +116,43 @@ export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required' | 'c
|
|
|
108
116
|
export type SessionPhase = 'running' | 'waiting' | 'failed' | 'done' | 'idle';
|
|
109
117
|
/**
|
|
110
118
|
* Which rung of the recap ladder produced a row's shown {@link ActiveSession.title}
|
|
111
|
-
* (RUSH-3011), best-first:
|
|
112
|
-
* - `label`
|
|
113
|
-
*
|
|
114
|
-
* - `
|
|
115
|
-
* - `prompt`
|
|
119
|
+
* (RUSH-3011, reshaped by PHNX-3797), best-first:
|
|
120
|
+
* - `label` — a `/rename` or harness-set label (incl. a harness-generated
|
|
121
|
+
* title, which lands in `label`); always wins.
|
|
122
|
+
* - `generated` — the daemon-generated session title (`generatedTitle`).
|
|
123
|
+
* - `prompt` — the first-user-prompt topic; the honest fallback while the
|
|
124
|
+
* titler has not reached this session.
|
|
125
|
+
*
|
|
126
|
+
* There is deliberately no rung for the agent's last transcript line: it is a
|
|
127
|
+
* rolling monologue, not what the session IS. It stays on the row as
|
|
128
|
+
* `lastAgentLine` for the separate live preview.
|
|
116
129
|
*/
|
|
117
|
-
export type RecapSource = 'label' | '
|
|
130
|
+
export type RecapSource = 'label' | 'generated' | 'prompt';
|
|
131
|
+
/**
|
|
132
|
+
* How urgent a row's secondary line is (PHNX-3797 owner feedback), best-first:
|
|
133
|
+
* - `question` — the agent asked something and is parked on the answer;
|
|
134
|
+
* - `needs_you` — blocked on the operator (a plan review, a permission, or a
|
|
135
|
+
* generic input-required wait) — the "needs you" case;
|
|
136
|
+
* - `activity` — nothing blocking; just what the agent is doing right now.
|
|
137
|
+
*
|
|
138
|
+
* `question`/`needs_you` are the signals a plain rolling activity preview buries,
|
|
139
|
+
* which is why they get their own ranked line distinct from the {@link ActiveSession.title}
|
|
140
|
+
* headline.
|
|
141
|
+
*/
|
|
142
|
+
export type ImportantMessageKind = 'question' | 'needs_you' | 'activity';
|
|
143
|
+
/**
|
|
144
|
+
* The single most important recent agent message for a row's secondary line
|
|
145
|
+
* (PHNX-3797 owner feedback). Distinct from the headline ({@link ActiveSession.title}),
|
|
146
|
+
* which says what the session IS: this says what the agent is doing or waiting on
|
|
147
|
+
* RIGHT NOW, ranked by {@link deriveImportantMessage} so a blocking question or a
|
|
148
|
+
* needs-you beats generic activity.
|
|
149
|
+
*/
|
|
150
|
+
export interface SessionImportantMessage {
|
|
151
|
+
/** The message to show, trimmed + capped for a single line. */
|
|
152
|
+
text: string;
|
|
153
|
+
/** Its urgency class — drives ordering and any glyph/color the renderer adds. */
|
|
154
|
+
kind: ImportantMessageKind;
|
|
155
|
+
}
|
|
118
156
|
export interface ActiveSession {
|
|
119
157
|
context: ActiveContext;
|
|
120
158
|
kind: string;
|
|
@@ -148,12 +186,21 @@ export interface ActiveSession {
|
|
|
148
186
|
*/
|
|
149
187
|
lastUserMessage?: string;
|
|
150
188
|
/**
|
|
151
|
-
* The
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
189
|
+
* The daemon-generated session title (PHNX-3797) — a short technical label for
|
|
190
|
+
* what the session worked on, produced once per session by the `session-title`
|
|
191
|
+
* service and backfilled from the index by {@link backfillActiveRowsFromMeta}.
|
|
192
|
+
* Rung 2 of the headline ladder; see {@link title}.
|
|
193
|
+
*/
|
|
194
|
+
generatedTitle?: string;
|
|
195
|
+
/**
|
|
196
|
+
* The row's shown title — WHAT the session is, best-source-wins (RUSH-3011,
|
|
197
|
+
* reshaped by PHNX-3797). The ladder ({@link deriveSessionRecap}): a `/rename`
|
|
198
|
+
* or harness `label` → the daemon-generated `generatedTitle` → the classified
|
|
199
|
+
* user prompt (from {@link lastUserMessage}/{@link firstUserMessage}/`topic`,
|
|
200
|
+
* PHNX-3939). It is a user-anchored NAME, never the agent's latest turn — that
|
|
201
|
+
* line stays in `lastAgentLine`/`preview`, where a live rolling status belongs.
|
|
202
|
+
* Folded on at the end of {@link getActiveSessions} (and re-derived after an
|
|
203
|
+
* index backfill); `recapSource` names which rung produced it.
|
|
157
204
|
*/
|
|
158
205
|
title?: string;
|
|
159
206
|
/** Which ladder rung produced {@link title}. */
|
|
@@ -168,11 +215,22 @@ export interface ActiveSession {
|
|
|
168
215
|
userPromptKind?: UserPromptKind;
|
|
169
216
|
/**
|
|
170
217
|
* The most recent assistant line (from the transcript tail) — the free,
|
|
171
|
-
* always-current signal of what the agent last said/did.
|
|
218
|
+
* always-current signal of what the agent last said/did. A LIVE-status field
|
|
219
|
+
* that belongs next to `preview`/`activity`; it is deliberately not a rung of
|
|
220
|
+
* the {@link title} ladder (PHNX-3797).
|
|
172
221
|
*/
|
|
173
222
|
lastAgentLine?: string;
|
|
174
223
|
/** Live preview: the latest turn (agent message or tool action), from the state engine. */
|
|
175
224
|
preview?: string;
|
|
225
|
+
/**
|
|
226
|
+
* The row's SECONDARY line (PHNX-3797 owner feedback): the most important recent
|
|
227
|
+
* agent message — a pending question, a needs-you block, or the current activity —
|
|
228
|
+
* ranked by {@link deriveImportantMessage}. Distinct from the {@link title}
|
|
229
|
+
* headline; folded on beside it in {@link foldRecap} and carried on the same
|
|
230
|
+
* `sessions watch --json` / mirror feed (via spread) so AGI EXT can render a bold
|
|
231
|
+
* title over a dim secondary line without a second query.
|
|
232
|
+
*/
|
|
233
|
+
importantMessage?: SessionImportantMessage;
|
|
176
234
|
/** Inferred activity: working / waiting_input / idle (from the transcript tail). */
|
|
177
235
|
activity?: SessionActivity;
|
|
178
236
|
/**
|
|
@@ -937,7 +995,8 @@ export declare function sessionProcessIsLocal(s: Pick<ActiveSession, 'machine' |
|
|
|
937
995
|
export declare function sessionProcessHost(s: Pick<ActiveSession, 'machine' | 'offloadedFrom'>, self: string): string | undefined;
|
|
938
996
|
export declare function foldHostLink(rows: ActiveSession[]): void;
|
|
939
997
|
/**
|
|
940
|
-
*
|
|
998
|
+
* The headline ladder (PHNX-3797): an explicit `/rename` label, then the
|
|
999
|
+
* daemon-generated title, then the classified user prompt.
|
|
941
1000
|
*
|
|
942
1001
|
* The prompt rung classifies the RAW latest genuine user turn, not the
|
|
943
1002
|
* already-collapsed `topic` (PHNX-3939). Classifying `topic` was how a `/model`
|
|
@@ -946,8 +1005,15 @@ export declare function foldHostLink(rows: ActiveSession[]): void;
|
|
|
946
1005
|
* that scaffolding as the user's words. It also never saw the row's attachments,
|
|
947
1006
|
* so an image-only turn could not be recognized. A row that has already been
|
|
948
1007
|
* merged with a daemon-folded {@link ActiveSession.request} uses it directly.
|
|
1008
|
+
*
|
|
1009
|
+
* The agent's last transcript line is deliberately NOT a rung. It is still
|
|
1010
|
+
* returned as `lastAgentLine` — and still shown, in the separate live
|
|
1011
|
+
* preview/activity slot — but a rolling monologue is not what the session IS,
|
|
1012
|
+
* and using it as the headline is the defect this ladder fixes. A session the
|
|
1013
|
+
* titler has not reached yet falls back to the classified user prompt, never
|
|
1014
|
+
* to the agent's last line.
|
|
949
1015
|
*/
|
|
950
|
-
export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'topic' | 'tail' | 'firstUserMessage' | 'lastUserMessage' | 'attachments' | 'request'>): {
|
|
1016
|
+
export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'generatedTitle' | 'topic' | 'tail' | 'firstUserMessage' | 'lastUserMessage' | 'attachments' | 'request'>): {
|
|
951
1017
|
title?: string;
|
|
952
1018
|
recapSource?: RecapSource;
|
|
953
1019
|
userPromptClean?: string;
|
|
@@ -955,6 +1021,17 @@ export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 't
|
|
|
955
1021
|
lastAgentLine?: string;
|
|
956
1022
|
request?: SessionRequest;
|
|
957
1023
|
};
|
|
1024
|
+
/**
|
|
1025
|
+
* The row's SECONDARY line (PHNX-3797 owner feedback): the most important recent
|
|
1026
|
+
* agent message, ranked so the operator sees a block before generic chatter —
|
|
1027
|
+
* a pending question, then a needs-you wait (plan review / permission /
|
|
1028
|
+
* input-required), then the current activity (the agent's latest line). Returns
|
|
1029
|
+
* `undefined` only when there is nothing recent to show at all.
|
|
1030
|
+
*
|
|
1031
|
+
* Pure over the fields it reads so the ranking is unit-tested directly; folded
|
|
1032
|
+
* onto every row by {@link applyRecap} beside the {@link deriveSessionRecap} title.
|
|
1033
|
+
*/
|
|
1034
|
+
export declare function deriveImportantMessage(row: Pick<ActiveSession, 'status' | 'activity' | 'awaitingReason' | 'question' | 'preview' | 'lastAgentLine'>): SessionImportantMessage | undefined;
|
|
958
1035
|
/** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
|
|
959
1036
|
export declare function foldRecap(rows: ActiveSession[]): void;
|
|
960
1037
|
/**
|