@phnx-labs/agents-cli 1.22.109 → 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 +44 -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/fork.js +6 -4
- package/dist/commands/logs.js +2 -1
- package/dist/commands/sessions-backfill.d.ts +30 -0
- package/dist/commands/sessions-backfill.js +72 -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 +3 -1
- package/dist/commands/sessions-resume.js +32 -3
- package/dist/commands/sessions.js +44 -24
- 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/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.js +7 -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/fleet-shared-state.d.ts +2 -0
- package/dist/lib/hooks/install.js +14 -7
- package/dist/lib/mailbox-target.js +2 -1
- package/dist/lib/session/active.d.ts +93 -16
- package/dist/lib/session/active.js +80 -14
- 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/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-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/startup/root-command.d.ts +2 -0
- package/dist/lib/startup/root-command.js +22 -0
- 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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-title service (PHNX-3797) — the ONE generator of session headlines.
|
|
3
|
+
*
|
|
4
|
+
* Every session row's headline used to be the agent's latest transcript line.
|
|
5
|
+
* This service replaces it with a user-anchored NAME: it sweeps the local
|
|
6
|
+
* session index for recent sessions whose title is still the raw first user
|
|
7
|
+
* message, asks a {@link SessionTitleProvider} (the cloud cheap-model call by
|
|
8
|
+
* default) for a short action+object headline, and persists it
|
|
9
|
+
* (`lib/session/title.ts`). Because the value lands in the index, every consumer
|
|
10
|
+
* — the CLI list, the picker, `sessions watch --json`, the fleet mirror, AGI EXT
|
|
11
|
+
* — reads one title that was generated exactly once.
|
|
12
|
+
*
|
|
13
|
+
* Cost is bounded by construction, not by hope: at most
|
|
14
|
+
* {@link SESSION_TITLE_MAX_PER_TICK} generations per tick, only for sessions
|
|
15
|
+
* inside the display window, only for rows whose stored source key no longer
|
|
16
|
+
* matches their user text (so a titled session is a pure DB read forever after),
|
|
17
|
+
* and never for the titler's own spawned sessions. When the harness is missing
|
|
18
|
+
* or signed out every attempt fails, so the service backs off exponentially
|
|
19
|
+
* instead of respawning two processes a minute forever — the rows simply keep
|
|
20
|
+
* showing the user's own words, which is the honest fallback.
|
|
21
|
+
*/
|
|
22
|
+
import { runSessionTitleTick, SESSION_TITLE_MAX_PER_TICK } from '../session/title.js';
|
|
23
|
+
import { BasePeriodicService } from './service.js';
|
|
24
|
+
/** Cadence of the sweep. Slow on purpose: a title is not a live signal. */
|
|
25
|
+
const SESSION_TITLE_TICK_MS = 2 * 60_000;
|
|
26
|
+
/** Above the worst case (limit x the per-generation subprocess timeout), below the interval. */
|
|
27
|
+
const SESSION_TITLE_DEADLINE_MS = 110_000;
|
|
28
|
+
/** Ticks skipped after the first all-failed sweep, doubling up to the ceiling. */
|
|
29
|
+
const SESSION_TITLE_BACKOFF_START = 2;
|
|
30
|
+
/** ~1h at the tick cadence — long enough that a signed-out box stays quiet. */
|
|
31
|
+
const SESSION_TITLE_BACKOFF_MAX = 30;
|
|
32
|
+
export class SessionTitleService extends BasePeriodicService {
|
|
33
|
+
run;
|
|
34
|
+
id = 'session-title';
|
|
35
|
+
intervalMs = SESSION_TITLE_TICK_MS;
|
|
36
|
+
deadlineMs = SESSION_TITLE_DEADLINE_MS;
|
|
37
|
+
/** Let the box settle (shims, PATH, index warm) before spawning a harness. */
|
|
38
|
+
startupDelayMs = 60_000;
|
|
39
|
+
/** Ticks still to skip before the next attempt; 0 = attempt this tick. */
|
|
40
|
+
skipTicks = 0;
|
|
41
|
+
/** Skip count applied after the next all-failed sweep. */
|
|
42
|
+
backoff = 0;
|
|
43
|
+
/** `run` is the model-call seam (defaults to the real subprocess); tests inject one. */
|
|
44
|
+
constructor(run) {
|
|
45
|
+
super();
|
|
46
|
+
this.run = run;
|
|
47
|
+
}
|
|
48
|
+
async onStart(_ctx) { }
|
|
49
|
+
async onStop() { }
|
|
50
|
+
async onTick(ctx, signal) {
|
|
51
|
+
if (this.skipTicks > 0) {
|
|
52
|
+
this.skipTicks--;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const result = await runSessionTitleTick({
|
|
56
|
+
limit: SESSION_TITLE_MAX_PER_TICK,
|
|
57
|
+
signal,
|
|
58
|
+
...(this.run ? { run: this.run } : {}),
|
|
59
|
+
});
|
|
60
|
+
if (result.generated > 0) {
|
|
61
|
+
// A success clears the backoff: whatever was broken is working again.
|
|
62
|
+
this.backoff = 0;
|
|
63
|
+
ctx.log('INFO', `session-title: generated ${result.generated} title(s) (${result.cached} already current)`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (result.failed > 0) {
|
|
67
|
+
this.backoff = this.backoff === 0
|
|
68
|
+
? SESSION_TITLE_BACKOFF_START
|
|
69
|
+
: Math.min(this.backoff * 2, SESSION_TITLE_BACKOFF_MAX);
|
|
70
|
+
this.skipTicks = this.backoff;
|
|
71
|
+
ctx.log('WARN', `session-title: ${result.failed} generation(s) produced no title; backing off ${this.backoff} tick(s)`);
|
|
72
|
+
}
|
|
73
|
+
// Nothing generated and nothing failed = every recent session is titled.
|
|
74
|
+
// That is the steady state, and it logs nothing.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -32,13 +32,21 @@ import type { DaemonServiceId } from '../daemon-services.js';
|
|
|
32
32
|
* freshness gate is the CONSUMER of this producer's cadence: it skips the
|
|
33
33
|
* pushes when the last exchange is older than one usage-sync interval, so it
|
|
34
34
|
* must track this constant rather than its own equal-by-coincidence literal.
|
|
35
|
+
* Sourced from `usage-sync.ts`'s `USAGE_SYNC_INTERVAL_MS` rather than a
|
|
36
|
+
* second literal, so the two files can't drift out of sync with each other.
|
|
35
37
|
*/
|
|
36
38
|
export declare const USAGE_SYNC_TICK_MS: number;
|
|
39
|
+
/**
|
|
40
|
+
* Offset from auth-sync's 60s kickoff so the two never contend for the
|
|
41
|
+
* shared-repo lock. Auth fires at T+1m, T+16m, …; usage at T+8m, T+23m, …
|
|
42
|
+
* The git exchange deadline is 45s, so a 7-minute gap is the whole point.
|
|
43
|
+
*/
|
|
44
|
+
export declare const USAGE_SYNC_KICKOFF_MS: number;
|
|
37
45
|
export declare class UsageSyncService extends BasePeriodicService {
|
|
38
46
|
readonly id: DaemonServiceId;
|
|
39
47
|
readonly intervalMs: number;
|
|
40
48
|
readonly deadlineMs: number;
|
|
41
|
-
readonly startupDelayMs
|
|
49
|
+
readonly startupDelayMs: number;
|
|
42
50
|
protected onStart(_ctx: DaemonContext): Promise<void>;
|
|
43
51
|
protected onStop(): Promise<void>;
|
|
44
52
|
protected onTick(ctx: DaemonContext): Promise<void>;
|
|
@@ -26,15 +26,23 @@
|
|
|
26
26
|
* deadline and circuit breaker.
|
|
27
27
|
*/
|
|
28
28
|
import { BasePeriodicService } from './service.js';
|
|
29
|
+
import { USAGE_SYNC_INTERVAL_MS } from '../accounting/usage-sync.js';
|
|
29
30
|
/**
|
|
30
31
|
* The usage-sync tick cadence. Exported because auth-sync's credential-push
|
|
31
32
|
* freshness gate is the CONSUMER of this producer's cadence: it skips the
|
|
32
33
|
* pushes when the last exchange is older than one usage-sync interval, so it
|
|
33
34
|
* must track this constant rather than its own equal-by-coincidence literal.
|
|
35
|
+
* Sourced from `usage-sync.ts`'s `USAGE_SYNC_INTERVAL_MS` rather than a
|
|
36
|
+
* second literal, so the two files can't drift out of sync with each other.
|
|
34
37
|
*/
|
|
35
|
-
export const USAGE_SYNC_TICK_MS =
|
|
38
|
+
export const USAGE_SYNC_TICK_MS = USAGE_SYNC_INTERVAL_MS;
|
|
36
39
|
const USAGE_SYNC_DEADLINE_MS = 2 * 60_000;
|
|
37
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Offset from auth-sync's 60s kickoff so the two never contend for the
|
|
42
|
+
* shared-repo lock. Auth fires at T+1m, T+16m, …; usage at T+8m, T+23m, …
|
|
43
|
+
* The git exchange deadline is 45s, so a 7-minute gap is the whole point.
|
|
44
|
+
*/
|
|
45
|
+
export const USAGE_SYNC_KICKOFF_MS = 8 * 60_000;
|
|
38
46
|
export class UsageSyncService extends BasePeriodicService {
|
|
39
47
|
id = 'usage-sync';
|
|
40
48
|
intervalMs = USAGE_SYNC_TICK_MS;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* enabled without pulling in the whole daemon lifecycle.
|
|
8
8
|
*/
|
|
9
9
|
/** Every service the daemon can host. IDs are kebab-case and stable. */
|
|
10
|
-
export type DaemonServiceId = 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'session-summarizer' | 'attention-notify' | 'harness-update';
|
|
10
|
+
export type DaemonServiceId = 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'session-title' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'session-summarizer' | 'attention-notify' | 'harness-update';
|
|
11
11
|
/** Human-readable metadata for each service. */
|
|
12
12
|
export interface DaemonServiceDef {
|
|
13
13
|
id: DaemonServiceId;
|
|
@@ -107,6 +107,11 @@ export const DAEMON_SERVICES = [
|
|
|
107
107
|
title: 'Attention desktop banners',
|
|
108
108
|
description: 'Posts one actionable desktop banner per new attention item (question / permission / plan review / stall) so the macOS helper can answer it through agents feed answer (PHNX-4004).',
|
|
109
109
|
},
|
|
110
|
+
{
|
|
111
|
+
id: 'session-title',
|
|
112
|
+
title: 'Session titles',
|
|
113
|
+
description: 'Generates the short technical title shown as each session row\'s headline, once per session, with a cheap model — so a row is named by what the user asked for, never by the agent\'s latest message.',
|
|
114
|
+
},
|
|
110
115
|
{
|
|
111
116
|
id: 'auth-sync',
|
|
112
117
|
title: 'Auth bundle sync',
|
package/dist/lib/daemon-ticks.js
CHANGED
|
@@ -145,6 +145,7 @@ export async function runUsageRefreshTick(signal) {
|
|
|
145
145
|
const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
|
|
146
146
|
const { writeClaudeUsageCache, readClaudeUsageCache } = await import('./accounting/usage.js');
|
|
147
147
|
const { usageRateLimitedUntil } = await import('./usage-backoff.js');
|
|
148
|
+
const { machineId } = await import('./machine-id.js');
|
|
148
149
|
const r = await runUsageRefresh({
|
|
149
150
|
listAccounts: buildLocalUsageAccounts,
|
|
150
151
|
writeUsageCache: writeClaudeUsageCache,
|
|
@@ -156,6 +157,11 @@ export async function runUsageRefreshTick(signal) {
|
|
|
156
157
|
// Thread the supervisor deadline into each provider fetch so the tick's I/O
|
|
157
158
|
// is bounded by deadlineMs, not just each fetch's own 5s timeout (PHNX-3608).
|
|
158
159
|
signal,
|
|
160
|
+
pollerDevice: machineId(),
|
|
161
|
+
onSnapshotsChanged: async () => {
|
|
162
|
+
const { pushUsageSnapshotNow } = await import('./accounting/usage-sync.js');
|
|
163
|
+
await pushUsageSnapshotNow();
|
|
164
|
+
},
|
|
159
165
|
});
|
|
160
166
|
const { listProfiles } = await import('./profiles.js');
|
|
161
167
|
const { refreshDueByokUsage } = await import('./byok-usage.js');
|
|
@@ -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
|
|
@@ -2257,6 +2258,7 @@ export function pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, rem
|
|
|
2257
2258
|
return removed;
|
|
2258
2259
|
}
|
|
2259
2260
|
function trustCodexHooks(hooksPath) {
|
|
2261
|
+
const hookPaths = [...new Set([hooksPath, path.join(fs.realpathSync(path.dirname(hooksPath)), 'hooks.json')])];
|
|
2260
2262
|
const configPath = path.join(path.dirname(hooksPath), 'config.toml');
|
|
2261
2263
|
const hooksFile = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
2262
2264
|
let tomlConfig = {};
|
|
@@ -2294,16 +2296,16 @@ function trustCodexHooks(hooksPath) {
|
|
|
2294
2296
|
group.hooks.forEach((handler, handlerIdx) => {
|
|
2295
2297
|
if (handler.type !== 'command')
|
|
2296
2298
|
return;
|
|
2297
|
-
const
|
|
2299
|
+
const keys = hookPaths.map((file) => `${file}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`);
|
|
2298
2300
|
const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
|
|
2299
2301
|
// Preserve a user's explicit `enabled = false` for this exact hook;
|
|
2300
2302
|
// only (re)write the trust hash.
|
|
2301
|
-
const prior = existingState[key];
|
|
2302
2303
|
const entry = { trusted_hash: trustedHash };
|
|
2303
|
-
if (
|
|
2304
|
+
if (keys.some((key) => existingState[key]?.enabled === false)) {
|
|
2304
2305
|
entry.enabled = false;
|
|
2305
2306
|
}
|
|
2306
|
-
|
|
2307
|
+
for (const key of keys)
|
|
2308
|
+
hookState[key] = entry;
|
|
2307
2309
|
});
|
|
2308
2310
|
});
|
|
2309
2311
|
}
|
|
@@ -3283,8 +3285,8 @@ export async function installSessionTrackerHook(agent, version, home) {
|
|
|
3283
3285
|
if (!invocation) {
|
|
3284
3286
|
return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
|
|
3285
3287
|
}
|
|
3286
|
-
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3287
3288
|
try {
|
|
3289
|
+
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3288
3290
|
await execFileAsync(invocation.command, invocation.args, {
|
|
3289
3291
|
env,
|
|
3290
3292
|
encoding: 'utf8',
|
|
@@ -3328,8 +3330,8 @@ export function installSessionTrackerHookSync(agent, version, home) {
|
|
|
3328
3330
|
if (!invocation) {
|
|
3329
3331
|
return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
|
|
3330
3332
|
}
|
|
3331
|
-
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3332
3333
|
try {
|
|
3334
|
+
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3333
3335
|
execFileSync(invocation.command, invocation.args, {
|
|
3334
3336
|
env,
|
|
3335
3337
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -3346,5 +3348,10 @@ export function installSessionTrackerHookSync(agent, version, home) {
|
|
|
3346
3348
|
/** The hook installer must target this account, not the current global home. */
|
|
3347
3349
|
function sessionTrackerInstallEnv(agent, version, home) {
|
|
3348
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
|
+
}
|
|
3349
3356
|
return target ? { ...process.env, HOME: target, USERPROFILE: target } : { ...process.env };
|
|
3350
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
|
|
@@ -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
|
/**
|
|
@@ -136,6 +136,14 @@ export function filterCachedUnattributed(sessions, attributed, alive) {
|
|
|
136
136
|
return alive(s.pid, s.startedAtMs);
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Fold index-only enrichment onto live rows, then RE-DERIVE the recap for every
|
|
141
|
+
* row it touched. The re-derive is load-bearing: `foldRecap` runs inside
|
|
142
|
+
* {@link getActiveSessions}, before any caller reaches the index, so a `label`
|
|
143
|
+
* or `generatedTitle` that only the index knows would otherwise never reach the
|
|
144
|
+
* shown `title` (PHNX-3797). Doing it here rather than at each of the three
|
|
145
|
+
* backfill call sites is what keeps them from drifting apart.
|
|
146
|
+
*/
|
|
139
147
|
export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
140
148
|
for (const s of sessions) {
|
|
141
149
|
if (!s.sessionId)
|
|
@@ -153,6 +161,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
153
161
|
s.firstUserMessage = m.firstUserMessage;
|
|
154
162
|
if (!s.lastUserMessage && m.lastUserMessage)
|
|
155
163
|
s.lastUserMessage = m.lastUserMessage;
|
|
164
|
+
if (!s.generatedTitle && m.generatedTitle)
|
|
165
|
+
s.generatedTitle = m.generatedTitle;
|
|
156
166
|
if (!s.ticket && m.ticketId)
|
|
157
167
|
s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
|
|
158
168
|
if (!s.pr && m.prUrl)
|
|
@@ -181,6 +191,7 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
181
191
|
if (!Number.isNaN(ts))
|
|
182
192
|
s.lastActivityMs = ts;
|
|
183
193
|
}
|
|
194
|
+
applyRecap(s);
|
|
184
195
|
}
|
|
185
196
|
}
|
|
186
197
|
function loadBackfillMetaFor(sessions) {
|
|
@@ -2119,7 +2130,8 @@ function recapLine(s, max = 120) {
|
|
|
2119
2130
|
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
|
|
2120
2131
|
}
|
|
2121
2132
|
/**
|
|
2122
|
-
*
|
|
2133
|
+
* The headline ladder (PHNX-3797): an explicit `/rename` label, then the
|
|
2134
|
+
* daemon-generated title, then the classified user prompt.
|
|
2123
2135
|
*
|
|
2124
2136
|
* The prompt rung classifies the RAW latest genuine user turn, not the
|
|
2125
2137
|
* already-collapsed `topic` (PHNX-3939). Classifying `topic` was how a `/model`
|
|
@@ -2128,6 +2140,13 @@ function recapLine(s, max = 120) {
|
|
|
2128
2140
|
* that scaffolding as the user's words. It also never saw the row's attachments,
|
|
2129
2141
|
* so an image-only turn could not be recognized. A row that has already been
|
|
2130
2142
|
* merged with a daemon-folded {@link ActiveSession.request} uses it directly.
|
|
2143
|
+
*
|
|
2144
|
+
* The agent's last transcript line is deliberately NOT a rung. It is still
|
|
2145
|
+
* returned as `lastAgentLine` — and still shown, in the separate live
|
|
2146
|
+
* preview/activity slot — but a rolling monologue is not what the session IS,
|
|
2147
|
+
* and using it as the headline is the defect this ladder fixes. A session the
|
|
2148
|
+
* titler has not reached yet falls back to the classified user prompt, never
|
|
2149
|
+
* to the agent's last line.
|
|
2131
2150
|
*/
|
|
2132
2151
|
export function deriveSessionRecap(row) {
|
|
2133
2152
|
const lastAgentLine = recapLine(row.tail?.length ? row.tail[row.tail.length - 1] : undefined);
|
|
@@ -2144,6 +2163,7 @@ export function deriveSessionRecap(row) {
|
|
|
2144
2163
|
const userPromptClean = tidied?.headline ?? fallback?.clean;
|
|
2145
2164
|
const userPromptKind = tidied?.kind ?? fallback?.kind;
|
|
2146
2165
|
const label = recapLine(row.label);
|
|
2166
|
+
const generated = recapLine(row.generatedTitle);
|
|
2147
2167
|
const prompt = recapLine(userPromptClean || row.topic);
|
|
2148
2168
|
let title;
|
|
2149
2169
|
let recapSource;
|
|
@@ -2151,9 +2171,9 @@ export function deriveSessionRecap(row) {
|
|
|
2151
2171
|
title = label;
|
|
2152
2172
|
recapSource = 'label';
|
|
2153
2173
|
}
|
|
2154
|
-
else if (
|
|
2155
|
-
title =
|
|
2156
|
-
recapSource = '
|
|
2174
|
+
else if (generated) {
|
|
2175
|
+
title = generated;
|
|
2176
|
+
recapSource = 'generated';
|
|
2157
2177
|
}
|
|
2158
2178
|
else if (prompt) {
|
|
2159
2179
|
title = prompt;
|
|
@@ -2168,18 +2188,63 @@ export function deriveSessionRecap(row) {
|
|
|
2168
2188
|
...(tidied ? { request: tidied } : {}),
|
|
2169
2189
|
};
|
|
2170
2190
|
}
|
|
2191
|
+
/**
|
|
2192
|
+
* The row's SECONDARY line (PHNX-3797 owner feedback): the most important recent
|
|
2193
|
+
* agent message, ranked so the operator sees a block before generic chatter —
|
|
2194
|
+
* a pending question, then a needs-you wait (plan review / permission /
|
|
2195
|
+
* input-required), then the current activity (the agent's latest line). Returns
|
|
2196
|
+
* `undefined` only when there is nothing recent to show at all.
|
|
2197
|
+
*
|
|
2198
|
+
* Pure over the fields it reads so the ranking is unit-tested directly; folded
|
|
2199
|
+
* onto every row by {@link applyRecap} beside the {@link deriveSessionRecap} title.
|
|
2200
|
+
*/
|
|
2201
|
+
export function deriveImportantMessage(row) {
|
|
2202
|
+
// The agent's latest words — the fallback text for every kind. `lastAgentLine`
|
|
2203
|
+
// is already trimmed/capped; `preview` (a live tool/turn line) is not.
|
|
2204
|
+
const recent = recapLine(row.preview) ?? row.lastAgentLine;
|
|
2205
|
+
// A pending question is the single most important thing an agent can be saying.
|
|
2206
|
+
if (row.awaitingReason === 'question' || row.question) {
|
|
2207
|
+
const text = recapLine(row.question?.text) ?? recent;
|
|
2208
|
+
if (text)
|
|
2209
|
+
return { text, kind: 'question' };
|
|
2210
|
+
}
|
|
2211
|
+
// Blocked on the operator: a plan review, a permission decision, or a bare
|
|
2212
|
+
// input-required wait. The "needs you" case the Fleet has to make loud.
|
|
2213
|
+
const needsYou = row.awaitingReason === 'plan_review'
|
|
2214
|
+
|| row.awaitingReason === 'permission'
|
|
2215
|
+
|| row.status === 'input_required'
|
|
2216
|
+
|| row.activity === 'waiting_input';
|
|
2217
|
+
if (needsYou) {
|
|
2218
|
+
const fallback = row.awaitingReason === 'plan_review'
|
|
2219
|
+
? 'Waiting on plan review'
|
|
2220
|
+
: row.awaitingReason === 'permission'
|
|
2221
|
+
? 'Waiting on a permission decision'
|
|
2222
|
+
: 'Waiting for you';
|
|
2223
|
+
return { text: recent ?? fallback, kind: 'needs_you' };
|
|
2224
|
+
}
|
|
2225
|
+
// Nothing blocking — just what the agent is doing now.
|
|
2226
|
+
if (recent)
|
|
2227
|
+
return { text: recent, kind: 'activity' };
|
|
2228
|
+
return undefined;
|
|
2229
|
+
}
|
|
2230
|
+
/** Write one row's recap fields from its current label/generatedTitle/topic/tail. */
|
|
2231
|
+
function applyRecap(s) {
|
|
2232
|
+
const recap = deriveSessionRecap(s);
|
|
2233
|
+
s.title = recap.title;
|
|
2234
|
+
s.recapSource = recap.recapSource;
|
|
2235
|
+
s.userPromptClean = recap.userPromptClean;
|
|
2236
|
+
s.userPromptKind = recap.userPromptKind;
|
|
2237
|
+
s.lastAgentLine = recap.lastAgentLine;
|
|
2238
|
+
if (recap.request)
|
|
2239
|
+
s.request = recap.request;
|
|
2240
|
+
// The secondary line rides the same fold as the headline (PHNX-3797), so every
|
|
2241
|
+
// consumer that reads `title` off a row reads `importantMessage` beside it.
|
|
2242
|
+
s.importantMessage = deriveImportantMessage(s);
|
|
2243
|
+
}
|
|
2171
2244
|
/** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
|
|
2172
2245
|
export function foldRecap(rows) {
|
|
2173
|
-
for (const s of rows)
|
|
2174
|
-
|
|
2175
|
-
s.title = recap.title;
|
|
2176
|
-
s.recapSource = recap.recapSource;
|
|
2177
|
-
s.userPromptClean = recap.userPromptClean;
|
|
2178
|
-
s.userPromptKind = recap.userPromptKind;
|
|
2179
|
-
s.lastAgentLine = recap.lastAgentLine;
|
|
2180
|
-
if (recap.request)
|
|
2181
|
-
s.request = recap.request;
|
|
2182
|
-
}
|
|
2246
|
+
for (const s of rows)
|
|
2247
|
+
applyRecap(s);
|
|
2183
2248
|
}
|
|
2184
2249
|
/**
|
|
2185
2250
|
* Project a {@link SessionPhase} from the finalized {@link ActiveStatus} (PHNX-2484).
|
|
@@ -2233,6 +2298,7 @@ export function annotateOrchestratorLabels(sessions) {
|
|
|
2233
2298
|
if (!s.orchestratorSessionId)
|
|
2234
2299
|
continue;
|
|
2235
2300
|
const orch = byId.get(s.orchestratorSessionId);
|
|
2301
|
+
// ladder-exempt: the team orchestrator's LABEL, resolved before any index backfill (no generatedTitle exists yet); not a headline render.
|
|
2236
2302
|
if (orch)
|
|
2237
2303
|
s.orchestratorLabel = orch.label || orch.topic || undefined;
|
|
2238
2304
|
}
|