@phnx-labs/agents-cli 1.22.109 → 1.22.111

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/bootstrap.js +3 -2
  3. package/dist/commands/browser-sessions-picker.js +2 -1
  4. package/dist/commands/computer-sessions-picker.js +2 -1
  5. package/dist/commands/cost.js +2 -1
  6. package/dist/commands/fork.js +6 -4
  7. package/dist/commands/logs.js +2 -1
  8. package/dist/commands/sessions-backfill.d.ts +30 -0
  9. package/dist/commands/sessions-backfill.js +72 -0
  10. package/dist/commands/sessions-inject.d.ts +3 -3
  11. package/dist/commands/sessions-inject.js +5 -11
  12. package/dist/commands/sessions-picker.js +10 -7
  13. package/dist/commands/sessions-resume.d.ts +3 -1
  14. package/dist/commands/sessions-resume.js +32 -3
  15. package/dist/commands/sessions.js +44 -24
  16. package/dist/commands/utils.d.ts +9 -0
  17. package/dist/commands/utils.js +18 -0
  18. package/dist/commands/watchdog.js +4 -1
  19. package/dist/lib/accounting/rotate.d.ts +9 -0
  20. package/dist/lib/accounting/rotate.js +17 -1
  21. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  22. package/dist/lib/accounting/usage-sync.js +70 -6
  23. package/dist/lib/accounting/usage.d.ts +32 -0
  24. package/dist/lib/accounting/usage.js +37 -2
  25. package/dist/lib/auth-health.js +19 -0
  26. package/dist/lib/claude-statusline.js +5 -0
  27. package/dist/lib/computer/sessions-list.js +2 -1
  28. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  29. package/dist/lib/daemon/auth-sync-service.js +2 -2
  30. package/dist/lib/daemon/daemon.js +7 -0
  31. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  32. package/dist/lib/daemon/session-title-service.js +76 -0
  33. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  34. package/dist/lib/daemon/usage-sync-service.js +10 -2
  35. package/dist/lib/daemon-services.d.ts +1 -1
  36. package/dist/lib/daemon-services.js +5 -0
  37. package/dist/lib/daemon-ticks.js +6 -0
  38. package/dist/lib/fleet-shared-state.d.ts +2 -0
  39. package/dist/lib/hooks/install.js +14 -7
  40. package/dist/lib/mailbox-target.js +2 -1
  41. package/dist/lib/remote-agents-json.js +1 -1
  42. package/dist/lib/session/active.d.ts +93 -16
  43. package/dist/lib/session/active.js +80 -14
  44. package/dist/lib/session/db.d.ts +44 -1
  45. package/dist/lib/session/db.js +122 -17
  46. package/dist/lib/session/fork.d.ts +10 -2
  47. package/dist/lib/session/fork.js +11 -2
  48. package/dist/lib/session/live-metadata.js +6 -0
  49. package/dist/lib/session/mirror.d.ts +3 -2
  50. package/dist/lib/session/mirror.js +5 -2
  51. package/dist/lib/session/remote/remote-list.js +1 -1
  52. package/dist/lib/session/remote/watch.js +18 -8
  53. package/dist/lib/session/title.d.ts +256 -0
  54. package/dist/lib/session/title.js +312 -0
  55. package/dist/lib/session/tool-index.d.ts +5 -0
  56. package/dist/lib/session/tool-index.js +1 -0
  57. package/dist/lib/session/types.d.ts +11 -0
  58. package/dist/lib/startup/root-command.d.ts +2 -0
  59. package/dist/lib/startup/root-command.js +22 -0
  60. package/dist/lib/traces/sync.js +1 -0
  61. package/dist/lib/usage-refresh.d.ts +58 -7
  62. package/dist/lib/usage-refresh.js +145 -23
  63. package/dist/lib/watchdog/runner.d.ts +3 -0
  64. package/dist/lib/watchdog/runner.js +1 -0
  65. package/dist/session-tracker/dist/install-hook.js +4 -4
  66. package/package.json +1 -1
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Daemon-generated session TITLES (PHNX-3797).
3
+ *
4
+ * The headline on a session row answers "what is this session about?", so it has
5
+ * to be anchored in what the USER asked for. It used to be the agent's latest
6
+ * transcript line — verbose, rolling, and unrecognizable to the person who
7
+ * started the run. The fix is a two-rung answer:
8
+ *
9
+ * 1. INSTANT, free: the user's own first message (already in the index) is the
10
+ * honest fallback shown from the moment the session appears.
11
+ * 2. UPGRADED, once: this module asks a CHEAP model (via a swappable
12
+ * {@link SessionTitleProvider}; the default {@link CloudSessionTitleProvider}
13
+ * uses the `cheap` tier — haiku on Claude — through the same
14
+ * `agents run --model <tier>` resolution every other call uses) for a short
15
+ * ACTION + OBJECT headline of what the session is doing, and persists it in
16
+ * the session index.
17
+ *
18
+ * Generation happens ONCE per session, in the daemon, and is keyed by
19
+ * {@link sessionTitleSourceKey} — a hash of the user text the title was derived
20
+ * from — so a sweep that sees a stored key matching the row's current text skips
21
+ * it (the cache hit) and regenerates only when that first user message actually
22
+ * changed, or when an operator asks explicitly
23
+ * (`agents sessions backfill titles --refresh`). There is no per-tick model call
24
+ * and no per-client generation: the daemon writes one value, every consumer
25
+ * (local list, picker, `sessions watch --json`, the fleet mirror, AGI EXT) reads
26
+ * it off the row.
27
+ *
28
+ * Everything here except {@link runSessionTitleTick} is pure, and the tick takes
29
+ * an injectable runner seam, so the whole path is testable without spawning a
30
+ * harness.
31
+ */
32
+ import type { SessionTitleCandidateRow } from './db.js';
33
+ /**
34
+ * The phrase every generated-title prompt carries. Load-bearing twice over:
35
+ * `traces/sync.ts` classifies a session whose topic matches it as internal
36
+ * `utility` plumbing rather than agent work, and {@link isSessionTitlePrompt}
37
+ * uses it to keep the titler from titling its OWN spawned sessions — which
38
+ * would otherwise be a runaway loop, since each generation creates one more
39
+ * untitled session.
40
+ *
41
+ * It is a STABLE sentinel, not the human-readable instruction: keep it exact and
42
+ * keep `traces/sync.ts`'s matching regex in sync when it changes. The surrounding
43
+ * prompt (see {@link renderSessionTitlePrompt}) asks for an action+object headline
44
+ * of 4–8 words; the sentinel deliberately carries no word budget so the two never
45
+ * drift.
46
+ */
47
+ export declare const SESSION_TITLE_PROMPT_MARKER = "Generate a concise session headline";
48
+ /** Hard ceiling on a stored title; the prompt asks for far less. */
49
+ export declare const SESSION_TITLE_MAX_CHARS = 60;
50
+ /** Hard ceiling on words kept from a model reply that ignored the word budget. */
51
+ export declare const SESSION_TITLE_MAX_WORDS = 8;
52
+ /** How much user text the prompt carries — enough to be specific, bounded for cost. */
53
+ export declare const SESSION_TITLE_INPUT_MAX_CHARS = 2000;
54
+ /** How many sessions one periodic sweep may generate for. */
55
+ export declare const SESSION_TITLE_MAX_PER_TICK = 2;
56
+ /** How many recent rows a sweep inspects before picking that batch. */
57
+ export declare const SESSION_TITLE_CANDIDATE_SCAN = 200;
58
+ /** Only sessions active within this window are titled — older rows are not shown. */
59
+ export declare const SESSION_TITLE_MAX_AGE_MS: number;
60
+ /** Per-generation subprocess ceiling. A title is not worth waiting on. */
61
+ export declare const SESSION_TITLE_TIMEOUT_MS = 45000;
62
+ /** The harnesses the titler will run as, best first. The first installed one wins. */
63
+ export declare const SESSION_TITLE_AGENTS: readonly ["claude", "codex", "grok", "kimi", "opencode"];
64
+ /** The user text a title is derived from, plus the context that makes it technical. */
65
+ export interface SessionTitleInput {
66
+ firstUserMessage?: string | null;
67
+ topic?: string | null;
68
+ project?: string | null;
69
+ ticketId?: string | null;
70
+ gitBranch?: string | null;
71
+ }
72
+ /** The user text itself — the ONLY thing the source key is computed over. */
73
+ export declare function sessionTitleSourceText(input: SessionTitleInput): string;
74
+ /**
75
+ * Stable identity of the text a title was generated from. Storing this beside
76
+ * the title is what makes "already titled" distinguishable from "the user's
77
+ * first message changed" without keeping a second copy of that text.
78
+ * Empty text yields `null`: there is nothing to title.
79
+ */
80
+ export declare function sessionTitleSourceKey(input: SessionTitleInput): string | null;
81
+ /**
82
+ * True when this text is one of the titler's OWN prompts. The titler runs a real
83
+ * harness, which writes a real transcript, which lands in the index as another
84
+ * untitled session — so without this guard every title generated would create
85
+ * work for the next sweep, forever.
86
+ */
87
+ export declare function isSessionTitlePrompt(...values: Array<string | null | undefined>): boolean;
88
+ /**
89
+ * The one-shot prompt handed to the cheap model.
90
+ *
91
+ * It asks for a descriptive ACTION + OBJECT headline (a verb phrase naming the
92
+ * concrete task, "Triage the AGI board", "Rename browser profile — default
93
+ * confusion"), not a single terse noun ("Triage") and not a full sentence — the
94
+ * headline slot has to tell the person who started the run what the session is
95
+ * doing at a glance. The word budget is soft in the prompt and hard-enforced by
96
+ * {@link sanitizeGeneratedTitle}'s {@link SESSION_TITLE_MAX_WORDS} ceiling.
97
+ */
98
+ export declare function renderSessionTitlePrompt(input: SessionTitleInput): string;
99
+ /**
100
+ * Reduce a model reply to a storable title, or `undefined` when it produced
101
+ * nothing usable. Takes the first non-empty line (a chatty model puts the title
102
+ * first), strips wrapping quotes/backticks and trailing punctuation, and applies
103
+ * the word + character ceilings. A reply that is really a refusal or an
104
+ * explanation fails the ceilings and is dropped rather than shown.
105
+ */
106
+ export declare function sanitizeGeneratedTitle(raw: string | null | undefined): string | undefined;
107
+ /**
108
+ * Compile-time guard: resolves to `T` only when `T` actually declares a
109
+ * `generatedTitle` key, and to `never` otherwise.
110
+ *
111
+ * This exists because structural typing makes the obvious signature useless. A
112
+ * parameter typed `{ generatedTitle?: string }` is satisfied by an object type
113
+ * that has no such property at all, so passing a projection that DROPPED the
114
+ * field — the watchdog's `SessionOutcome`, which copied `label`/`name`/`topic`
115
+ * off the session and left `generatedTitle` behind — compiles cleanly and
116
+ * silently degrades {@link sessionHeadline} back to `label || topic`. That is a
117
+ * real bug this feature shipped once and a lexical lint cannot see, since the
118
+ * call site looks correct. `keyof` includes optional keys, so every legitimate
119
+ * carrier (`SessionMeta`, `ActiveSession`, a `Pick<>` that names it) still
120
+ * passes; only a type that never modelled the rung is rejected.
121
+ */
122
+ type CarriesTitleRung<T> = 'generatedTitle' extends keyof T ? T : never;
123
+ /**
124
+ * The headline for an INDEXED session row, on the same ladder the live path uses
125
+ * (`deriveSessionRecap`, active.ts): `/rename` label → daemon-generated title →
126
+ * first-prompt topic. Every `agents sessions` surface that shows "what this
127
+ * session is" reads it from here, so the CLI and the watch stream can never
128
+ * disagree about a session's name (PHNX-3797).
129
+ *
130
+ * A caller whose row type does not carry `generatedTitle` fails to compile
131
+ * (see {@link CarriesTitleRung}) — fix the projection to carry the field rather
132
+ * than casting past this.
133
+ */
134
+ export declare function sessionHeadline<T extends {
135
+ label?: string | null;
136
+ generatedTitle?: string | null;
137
+ topic?: string | null;
138
+ }>(row: CarriesTitleRung<T>): string | undefined;
139
+ /**
140
+ * Runs the cheap model once and returns its raw stdout. Injectable so tests
141
+ * exercise the whole tick — candidate selection, key comparison, persistence —
142
+ * without spawning a harness.
143
+ */
144
+ export type SessionTitleRunner = (prompt: string, signal?: AbortSignal) => Promise<string>;
145
+ /** The harness the titler runs as on this box, or null when none is installed. */
146
+ export declare function resolveSessionTitleAgent(): Promise<string | null>;
147
+ /**
148
+ * The real runner: ONE `agents run <agent> --mode plan --model cheap <prompt>`
149
+ * subprocess. `--mode plan` keeps it read-only (it must never touch a repo), and
150
+ * `--model cheap` goes through the same tier resolution every other run uses, so
151
+ * the box's own catalog picks haiku (or its per-harness equivalent) rather than
152
+ * this module hardcoding a model id that ages out.
153
+ */
154
+ export declare function defaultSessionTitleRunner(prompt: string, signal?: AbortSignal): Promise<string>;
155
+ /**
156
+ * A pluggable backend that turns a session's user text into a raw headline reply
157
+ * — the ONE part of title generation that varies by model host (PHNX-3797).
158
+ *
159
+ * The tick decides WHICH sessions need a title, caches by source key, sanitizes,
160
+ * and persists — none of that is provider-specific, so only the model call itself
161
+ * sits behind this interface. The shipped default is
162
+ * {@link CloudSessionTitleProvider} (one cheap cloud-model subprocess). A LOCAL
163
+ * backend — e.g. an ollama 1–3B instruct model over HTTP — is a drop-in: implement
164
+ * `generate` and hand the instance to {@link runSessionTitleTick} (or construct
165
+ * {@link SessionTitleService} with it). No call site inside the tick changes, and
166
+ * the shared {@link sanitizeGeneratedTitle} still enforces the word/character
167
+ * ceilings on whatever text the backend returns.
168
+ *
169
+ * `generate` returns the model's RAW reply (the tick owns sanitizing). It MAY
170
+ * throw — harness missing, signed out, timed out — and the tick treats a throw as
171
+ * "no title this sweep", leaving the row on the user's own words and backing off.
172
+ */
173
+ export interface SessionTitleProvider {
174
+ /** Stable id for logs/diagnostics (e.g. `'cloud'`, `'ollama'`). */
175
+ readonly name: string;
176
+ /** Produce a raw headline reply for one session's user text, or throw. */
177
+ generate(input: SessionTitleInput, signal?: AbortSignal): Promise<string>;
178
+ }
179
+ /**
180
+ * The default, shipped provider: render the shared prompt and run it through a
181
+ * {@link SessionTitleRunner} — by default {@link defaultSessionTitleRunner}, the
182
+ * one cheap `agents run --model cheap` subprocess. Tests inject a fake runner (or
183
+ * a whole fake provider) to exercise the tick without spawning a harness.
184
+ */
185
+ export declare class CloudSessionTitleProvider implements SessionTitleProvider {
186
+ private readonly runner;
187
+ readonly name = "cloud";
188
+ constructor(runner?: SessionTitleRunner);
189
+ generate(input: SessionTitleInput, signal?: AbortSignal): Promise<string>;
190
+ }
191
+ /** The provider used when a caller injects neither a `provider` nor a `run`. */
192
+ export declare function defaultSessionTitleProvider(): SessionTitleProvider;
193
+ export interface SessionTitleTickOptions {
194
+ /** Max sessions generated for in this sweep. */
195
+ limit?: number;
196
+ /** Title this session specifically (an explicit refresh), ignoring the recency window. */
197
+ id?: string;
198
+ /** Regenerate even when the stored key still matches the row's user text. */
199
+ force?: boolean;
200
+ /**
201
+ * The generation backend. Defaults to {@link defaultSessionTitleProvider}
202
+ * (the cheap cloud model). Swap this for a local model without touching the
203
+ * tick. Takes precedence over {@link SessionTitleTickOptions.run}.
204
+ */
205
+ provider?: SessionTitleProvider;
206
+ /**
207
+ * Shortcut seam for the cloud provider's raw model call — wrapped in a
208
+ * {@link CloudSessionTitleProvider} when no {@link provider} is given. Kept for
209
+ * the daemon service and the tests that inject only the subprocess.
210
+ */
211
+ run?: SessionTitleRunner;
212
+ signal?: AbortSignal;
213
+ nowMs?: number;
214
+ maxAgeMs?: number;
215
+ }
216
+ export interface SessionTitleTickResult {
217
+ /** Rows inspected. */
218
+ scanned: number;
219
+ /** Rows already carrying a title for their current user text (the cache hit). */
220
+ cached: number;
221
+ /** Titles generated and persisted this sweep. */
222
+ generated: number;
223
+ /** Generation attempts that produced nothing usable (model unavailable, empty reply). */
224
+ failed: number;
225
+ titles: Array<{
226
+ id: string;
227
+ title: string;
228
+ }>;
229
+ }
230
+ /**
231
+ * Decide what one sweep should do, without touching the model. Pure over its
232
+ * inputs so the "generate once, then cache-hit" property is directly testable:
233
+ * a candidate is work only when its user text yields a key AND that key differs
234
+ * from the one stored beside its title (or `force`).
235
+ */
236
+ export declare function selectSessionsNeedingTitle(rows: SessionTitleCandidateRow[], opts?: {
237
+ limit: number;
238
+ force?: boolean;
239
+ }): {
240
+ pending: Array<{
241
+ row: SessionTitleCandidateRow;
242
+ sourceKey: string;
243
+ }>;
244
+ cached: number;
245
+ };
246
+ /**
247
+ * One titling sweep: pick the sessions whose headline is still the raw user
248
+ * message, generate a title for at most {@link SessionTitleTickOptions.limit} of
249
+ * them, and persist each. Best-effort by contract — a failed generation leaves
250
+ * the row untitled, which the ladder renders as the user's own first message.
251
+ *
252
+ * Throws only for an explicit {@link SessionTitleTickOptions.id} that matches no
253
+ * indexed session (a caller error); the periodic sweep never throws.
254
+ */
255
+ export declare function runSessionTitleTick(options?: SessionTitleTickOptions): Promise<SessionTitleTickResult>;
256
+ export {};
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Daemon-generated session TITLES (PHNX-3797).
3
+ *
4
+ * The headline on a session row answers "what is this session about?", so it has
5
+ * to be anchored in what the USER asked for. It used to be the agent's latest
6
+ * transcript line — verbose, rolling, and unrecognizable to the person who
7
+ * started the run. The fix is a two-rung answer:
8
+ *
9
+ * 1. INSTANT, free: the user's own first message (already in the index) is the
10
+ * honest fallback shown from the moment the session appears.
11
+ * 2. UPGRADED, once: this module asks a CHEAP model (via a swappable
12
+ * {@link SessionTitleProvider}; the default {@link CloudSessionTitleProvider}
13
+ * uses the `cheap` tier — haiku on Claude — through the same
14
+ * `agents run --model <tier>` resolution every other call uses) for a short
15
+ * ACTION + OBJECT headline of what the session is doing, and persists it in
16
+ * the session index.
17
+ *
18
+ * Generation happens ONCE per session, in the daemon, and is keyed by
19
+ * {@link sessionTitleSourceKey} — a hash of the user text the title was derived
20
+ * from — so a sweep that sees a stored key matching the row's current text skips
21
+ * it (the cache hit) and regenerates only when that first user message actually
22
+ * changed, or when an operator asks explicitly
23
+ * (`agents sessions backfill titles --refresh`). There is no per-tick model call
24
+ * and no per-client generation: the daemon writes one value, every consumer
25
+ * (local list, picker, `sessions watch --json`, the fleet mirror, AGI EXT) reads
26
+ * it off the row.
27
+ *
28
+ * Everything here except {@link runSessionTitleTick} is pure, and the tick takes
29
+ * an injectable runner seam, so the whole path is testable without spawning a
30
+ * harness.
31
+ */
32
+ import { createHash } from 'node:crypto';
33
+ /**
34
+ * The phrase every generated-title prompt carries. Load-bearing twice over:
35
+ * `traces/sync.ts` classifies a session whose topic matches it as internal
36
+ * `utility` plumbing rather than agent work, and {@link isSessionTitlePrompt}
37
+ * uses it to keep the titler from titling its OWN spawned sessions — which
38
+ * would otherwise be a runaway loop, since each generation creates one more
39
+ * untitled session.
40
+ *
41
+ * It is a STABLE sentinel, not the human-readable instruction: keep it exact and
42
+ * keep `traces/sync.ts`'s matching regex in sync when it changes. The surrounding
43
+ * prompt (see {@link renderSessionTitlePrompt}) asks for an action+object headline
44
+ * of 4–8 words; the sentinel deliberately carries no word budget so the two never
45
+ * drift.
46
+ */
47
+ export const SESSION_TITLE_PROMPT_MARKER = 'Generate a concise session headline';
48
+ /** Hard ceiling on a stored title; the prompt asks for far less. */
49
+ export const SESSION_TITLE_MAX_CHARS = 60;
50
+ /** Hard ceiling on words kept from a model reply that ignored the word budget. */
51
+ export const SESSION_TITLE_MAX_WORDS = 8;
52
+ /** How much user text the prompt carries — enough to be specific, bounded for cost. */
53
+ export const SESSION_TITLE_INPUT_MAX_CHARS = 2000;
54
+ /** How many sessions one periodic sweep may generate for. */
55
+ export const SESSION_TITLE_MAX_PER_TICK = 2;
56
+ /** How many recent rows a sweep inspects before picking that batch. */
57
+ export const SESSION_TITLE_CANDIDATE_SCAN = 200;
58
+ /** Only sessions active within this window are titled — older rows are not shown. */
59
+ export const SESSION_TITLE_MAX_AGE_MS = 14 * 24 * 60 * 60_000;
60
+ /** Per-generation subprocess ceiling. A title is not worth waiting on. */
61
+ export const SESSION_TITLE_TIMEOUT_MS = 45_000;
62
+ /** The harnesses the titler will run as, best first. The first installed one wins. */
63
+ export const SESSION_TITLE_AGENTS = ['claude', 'codex', 'grok', 'kimi', 'opencode'];
64
+ /** The user text itself — the ONLY thing the source key is computed over. */
65
+ export function sessionTitleSourceText(input) {
66
+ const raw = (input.firstUserMessage || input.topic || '').replace(/\s+/g, ' ').trim();
67
+ return raw.length > SESSION_TITLE_INPUT_MAX_CHARS ? raw.slice(0, SESSION_TITLE_INPUT_MAX_CHARS) : raw;
68
+ }
69
+ /**
70
+ * Stable identity of the text a title was generated from. Storing this beside
71
+ * the title is what makes "already titled" distinguishable from "the user's
72
+ * first message changed" without keeping a second copy of that text.
73
+ * Empty text yields `null`: there is nothing to title.
74
+ */
75
+ export function sessionTitleSourceKey(input) {
76
+ const text = sessionTitleSourceText(input);
77
+ if (!text)
78
+ return null;
79
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
80
+ }
81
+ /**
82
+ * True when this text is one of the titler's OWN prompts. The titler runs a real
83
+ * harness, which writes a real transcript, which lands in the index as another
84
+ * untitled session — so without this guard every title generated would create
85
+ * work for the next sweep, forever.
86
+ */
87
+ export function isSessionTitlePrompt(...values) {
88
+ return values.some((v) => typeof v === 'string' && v.includes(SESSION_TITLE_PROMPT_MARKER));
89
+ }
90
+ /**
91
+ * The one-shot prompt handed to the cheap model.
92
+ *
93
+ * It asks for a descriptive ACTION + OBJECT headline (a verb phrase naming the
94
+ * concrete task, "Triage the AGI board", "Rename browser profile — default
95
+ * confusion"), not a single terse noun ("Triage") and not a full sentence — the
96
+ * headline slot has to tell the person who started the run what the session is
97
+ * doing at a glance. The word budget is soft in the prompt and hard-enforced by
98
+ * {@link sanitizeGeneratedTitle}'s {@link SESSION_TITLE_MAX_WORDS} ceiling.
99
+ */
100
+ export function renderSessionTitlePrompt(input) {
101
+ const text = sessionTitleSourceText(input);
102
+ const context = [
103
+ input.project ? `Repository: ${input.project}` : null,
104
+ input.ticketId ? `Ticket: ${input.ticketId}` : null,
105
+ input.gitBranch ? `Branch: ${input.gitBranch}` : null,
106
+ ].filter(Boolean);
107
+ return [
108
+ `${SESSION_TITLE_PROMPT_MARKER} naming what this coding session is working on.`,
109
+ 'Write an ACTION + OBJECT headline of 4 to 8 words — a verb phrase naming the concrete task,',
110
+ 'e.g. "Triage the AGI board" or "Rename browser profile — default confusion".',
111
+ 'NOT a single noun, NOT a full sentence. Name the concrete feature, component, or fix —',
112
+ 'not the person, not the pleasantries.',
113
+ 'Respond IMMEDIATELY with only the headline. Do NOT investigate, do NOT read files, do NOT use any tools.',
114
+ 'No quotes, no trailing punctuation, no explanation.',
115
+ ...(context.length ? ['', ...context] : []),
116
+ '',
117
+ "The user's request:",
118
+ '---',
119
+ text,
120
+ '---',
121
+ ].join('\n');
122
+ }
123
+ /**
124
+ * Reduce a model reply to a storable title, or `undefined` when it produced
125
+ * nothing usable. Takes the first non-empty line (a chatty model puts the title
126
+ * first), strips wrapping quotes/backticks and trailing punctuation, and applies
127
+ * the word + character ceilings. A reply that is really a refusal or an
128
+ * explanation fails the ceilings and is dropped rather than shown.
129
+ */
130
+ export function sanitizeGeneratedTitle(raw) {
131
+ if (!raw)
132
+ return undefined;
133
+ const firstLine = raw.split('\n').map((l) => l.trim()).find((l) => l.length > 0);
134
+ if (!firstLine)
135
+ return undefined;
136
+ let title = firstLine
137
+ .replace(/^["'`*_\s]+/, '')
138
+ .replace(/["'`*_\s]+$/, '')
139
+ .replace(/[.!?,;:]+$/, '')
140
+ .replace(/\s+/g, ' ')
141
+ .trim();
142
+ if (!title)
143
+ return undefined;
144
+ // A model that ignored the budget gets truncated, not shown in full — a
145
+ // paragraph in the headline slot is the very failure this feature removes.
146
+ const words = title.split(' ');
147
+ if (words.length > SESSION_TITLE_MAX_WORDS)
148
+ title = words.slice(0, SESSION_TITLE_MAX_WORDS).join(' ');
149
+ if (title.length > SESSION_TITLE_MAX_CHARS)
150
+ title = title.slice(0, SESSION_TITLE_MAX_CHARS).trimEnd();
151
+ // Never store the prompt back as a title (a harness that echoed its input).
152
+ if (isSessionTitlePrompt(title))
153
+ return undefined;
154
+ return title || undefined;
155
+ }
156
+ /**
157
+ * The headline for an INDEXED session row, on the same ladder the live path uses
158
+ * (`deriveSessionRecap`, active.ts): `/rename` label → daemon-generated title →
159
+ * first-prompt topic. Every `agents sessions` surface that shows "what this
160
+ * session is" reads it from here, so the CLI and the watch stream can never
161
+ * disagree about a session's name (PHNX-3797).
162
+ *
163
+ * A caller whose row type does not carry `generatedTitle` fails to compile
164
+ * (see {@link CarriesTitleRung}) — fix the projection to carry the field rather
165
+ * than casting past this.
166
+ */
167
+ export function sessionHeadline(row) {
168
+ return row.label || row.generatedTitle || row.topic || undefined;
169
+ }
170
+ /** The harness the titler runs as on this box, or null when none is installed. */
171
+ export async function resolveSessionTitleAgent() {
172
+ // Lazy import: this module is also imported by render paths that must not pull
173
+ // the installation store (and its filesystem probes) into a hot listing.
174
+ const { listInstalledVersions } = await import('../installations/store.js');
175
+ for (const agent of SESSION_TITLE_AGENTS) {
176
+ try {
177
+ if (listInstalledVersions(agent).length > 0)
178
+ return agent;
179
+ }
180
+ catch {
181
+ // An unreadable store for one agent must not hide the others.
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+ /**
187
+ * The real runner: ONE `agents run <agent> --mode plan --model cheap <prompt>`
188
+ * subprocess. `--mode plan` keeps it read-only (it must never touch a repo), and
189
+ * `--model cheap` goes through the same tier resolution every other run uses, so
190
+ * the box's own catalog picks haiku (or its per-harness equivalent) rather than
191
+ * this module hardcoding a model id that ages out.
192
+ */
193
+ export async function defaultSessionTitleRunner(prompt, signal) {
194
+ const [{ getAgentsInvocation }, { execFile }, { promisify }] = await Promise.all([
195
+ import('../daemon/daemon.js'),
196
+ import('node:child_process'),
197
+ import('node:util'),
198
+ ]);
199
+ const agent = await resolveSessionTitleAgent();
200
+ if (!agent)
201
+ throw new Error('no installed harness can generate a session title');
202
+ const inv = getAgentsInvocation(['run', agent, '--mode', 'plan', '--model', 'cheap', prompt]);
203
+ const { stdout } = await promisify(execFile)(inv.command, inv.args, {
204
+ encoding: 'utf8',
205
+ maxBuffer: 1024 * 1024,
206
+ timeout: SESSION_TITLE_TIMEOUT_MS,
207
+ signal,
208
+ });
209
+ return stdout;
210
+ }
211
+ /**
212
+ * The default, shipped provider: render the shared prompt and run it through a
213
+ * {@link SessionTitleRunner} — by default {@link defaultSessionTitleRunner}, the
214
+ * one cheap `agents run --model cheap` subprocess. Tests inject a fake runner (or
215
+ * a whole fake provider) to exercise the tick without spawning a harness.
216
+ */
217
+ export class CloudSessionTitleProvider {
218
+ runner;
219
+ name = 'cloud';
220
+ constructor(runner = defaultSessionTitleRunner) {
221
+ this.runner = runner;
222
+ }
223
+ generate(input, signal) {
224
+ return this.runner(renderSessionTitlePrompt(input), signal);
225
+ }
226
+ }
227
+ /** The provider used when a caller injects neither a `provider` nor a `run`. */
228
+ export function defaultSessionTitleProvider() {
229
+ return new CloudSessionTitleProvider();
230
+ }
231
+ /**
232
+ * Decide what one sweep should do, without touching the model. Pure over its
233
+ * inputs so the "generate once, then cache-hit" property is directly testable:
234
+ * a candidate is work only when its user text yields a key AND that key differs
235
+ * from the one stored beside its title (or `force`).
236
+ */
237
+ export function selectSessionsNeedingTitle(rows, opts = { limit: SESSION_TITLE_MAX_PER_TICK }) {
238
+ const pending = [];
239
+ let cached = 0;
240
+ for (const row of rows) {
241
+ // The titler's own runs are sessions too; titling them would spawn another.
242
+ if (isSessionTitlePrompt(row.firstUserMessage, row.topic))
243
+ continue;
244
+ const sourceKey = sessionTitleSourceKey(row);
245
+ if (!sourceKey)
246
+ continue;
247
+ if (!opts.force && row.generatedTitle && row.generatedTitleKey === sourceKey) {
248
+ cached++;
249
+ continue;
250
+ }
251
+ if (pending.length < opts.limit)
252
+ pending.push({ row, sourceKey });
253
+ }
254
+ return { pending, cached };
255
+ }
256
+ /**
257
+ * One titling sweep: pick the sessions whose headline is still the raw user
258
+ * message, generate a title for at most {@link SessionTitleTickOptions.limit} of
259
+ * them, and persist each. Best-effort by contract — a failed generation leaves
260
+ * the row untitled, which the ladder renders as the user's own first message.
261
+ *
262
+ * Throws only for an explicit {@link SessionTitleTickOptions.id} that matches no
263
+ * indexed session (a caller error); the periodic sweep never throws.
264
+ */
265
+ export async function runSessionTitleTick(options = {}) {
266
+ const limit = options.limit ?? SESSION_TITLE_MAX_PER_TICK;
267
+ const now = options.nowMs ?? Date.now();
268
+ const result = { scanned: 0, cached: 0, generated: 0, failed: 0, titles: [] };
269
+ const { querySessionTitleCandidates, setSessionGeneratedTitle } = await import('./db.js');
270
+ const rows = querySessionTitleCandidates(options.id ? 1 : SESSION_TITLE_CANDIDATE_SCAN, options.id
271
+ ? { id: options.id }
272
+ : { sinceMs: now - (options.maxAgeMs ?? SESSION_TITLE_MAX_AGE_MS) });
273
+ result.scanned = rows.length;
274
+ // An explicitly requested session that has no indexed row is a caller error,
275
+ // not a quiet sweep outcome: reporting "0 generated" would read as "already
276
+ // current" for an id that was simply mistyped or not scanned yet.
277
+ if (options.id && rows.length === 0) {
278
+ throw new Error(`no indexed session matches "${options.id}" on this machine — ` +
279
+ `a session is titled after it is indexed (the daemon indexes within seconds), ` +
280
+ `and a peer's sessions are titled on the box that owns them`);
281
+ }
282
+ const { pending, cached } = selectSessionsNeedingTitle(rows, { limit, force: options.force });
283
+ result.cached = cached;
284
+ if (pending.length === 0)
285
+ return result;
286
+ // The provider is the ONLY thing that varies by model host; everything above
287
+ // (candidate selection, the source-key cache) and below (sanitize, persist) is
288
+ // shared. A local backend is dropped in here, not woven through the tick.
289
+ const provider = options.provider ?? new CloudSessionTitleProvider(options.run);
290
+ for (const { row, sourceKey } of pending) {
291
+ if (options.signal?.aborted)
292
+ break;
293
+ let title;
294
+ try {
295
+ title = sanitizeGeneratedTitle(await provider.generate(row, options.signal));
296
+ }
297
+ catch {
298
+ // Harness unavailable, signed out, or over its deadline. Best-effort: the
299
+ // row keeps showing the user's own words, and the caller backs off.
300
+ title = undefined;
301
+ }
302
+ if (!title) {
303
+ result.failed++;
304
+ continue;
305
+ }
306
+ if (setSessionGeneratedTitle(row.id, title, sourceKey, now)) {
307
+ result.generated++;
308
+ result.titles.push({ id: row.id, title });
309
+ }
310
+ }
311
+ return result;
312
+ }
@@ -31,6 +31,11 @@ export interface ToolSessionEvidence {
31
31
  cwd?: string;
32
32
  topic?: string;
33
33
  label?: string;
34
+ /** The daemon-generated headline (PHNX-3797). Carried so a consumer of this
35
+ * envelope names a session the same way every other surface does; a
36
+ * projection that drops it silently degrades `sessionHeadline` to
37
+ * `label || topic`. */
38
+ generatedTitle?: string;
34
39
  filePath?: string;
35
40
  calls: ToolCallEvidence[];
36
41
  }
@@ -869,6 +869,7 @@ export function searchToolCalls(sessions, clauseSources, coverage, resultLimit =
869
869
  cwd: session.cwd,
870
870
  topic: session.topic,
871
871
  label: session.label,
872
+ generatedTitle: session.generatedTitle,
872
873
  calls: selected,
873
874
  });
874
875
  if (matched.length >= resultLimit)
@@ -412,6 +412,17 @@ export interface SessionMeta {
412
412
  * single-turn session; absent for rows indexed before this field shipped.
413
413
  */
414
414
  lastUserMessage?: string;
415
+ /**
416
+ * The daemon-generated session TITLE (PHNX-3797) — a short, descriptive
417
+ * ACTION + OBJECT headline ("Triage the AGI board") for what the session is
418
+ * doing, produced ONCE per session by the `session-title` daemon service from
419
+ * the user's own first message (never from the agent's latest turn) and
420
+ * persisted in the index. It is the second rung of
421
+ * the headline ladder (`deriveSessionRecap`): an explicit `/rename` {@link label}
422
+ * still wins, and a session the titler has not reached yet falls back to
423
+ * `firstUserMessage`/`topic`, never to the agent's last line.
424
+ */
425
+ generatedTitle?: string;
415
426
  /**
416
427
  * The session's human-readable name — one field, several sources with a plain
417
428
  * priority: an agent-generated title / Claude `/rename` wins; else the launch
@@ -18,3 +18,5 @@ import type { Command } from 'commander';
18
18
  * instead (renaming the colliding options), not with this global flag.
19
19
  */
20
20
  export declare function configureRootCommand(program: Command, name: string, version: string): Command;
21
+ /** Resume takes one device; its parent listing command takes a variadic list. */
22
+ export declare function normalizeResumeDeviceArgs(args: string[]): string[];
@@ -26,3 +26,25 @@ export function configureRootCommand(program, name, version) {
26
26
  .helpOption('-h, --help', 'Show help')
27
27
  .addHelpCommand(false);
28
28
  }
29
+ /** Resume takes one device; its parent listing command takes a variadic list. */
30
+ export function normalizeResumeDeviceArgs(args) {
31
+ if (args[0] !== 'sessions' || args[1] !== 'resume')
32
+ return args;
33
+ let options = true;
34
+ return args.map((arg, index) => {
35
+ if (index < 2 || !options)
36
+ return arg;
37
+ if (arg === '--') {
38
+ options = false;
39
+ return arg;
40
+ }
41
+ if (arg === '--device' || arg === '--devices' || arg === '-D')
42
+ return '--resume-device';
43
+ if (arg.startsWith('--device=') || arg.startsWith('--devices=')) {
44
+ return `--resume-device=${arg.slice(arg.indexOf('=') + 1)}`;
45
+ }
46
+ if (arg.startsWith('-D'))
47
+ return `--resume-device=${arg.slice(arg[2] === '=' ? 3 : 2)}`;
48
+ return arg;
49
+ });
50
+ }
@@ -237,6 +237,7 @@ export async function syncTraces(opts = {}) {
237
237
  */
238
238
  const UTILITY_PROMPT_SIGNATURES = [
239
239
  /generate a 3-4 word title/i, // title generation
240
+ /generate a concise session headline/i, // session-title daemon service (PHNX-3797)
240
241
  /you are a watchdog|watchdog monitoring/i, // watchdog tick
241
242
  /conventional[- ]commit/i, // commit-message writer
242
243
  /factory worker/i, // dispatched factory worker