@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
|
@@ -12,6 +12,7 @@ import * as path from 'path';
|
|
|
12
12
|
import Database from '../sqlite.js';
|
|
13
13
|
import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput, unwrapUserQuery } from './prompt.js';
|
|
14
14
|
import { structuredToolResult, commandsFromCodexExec } from './tool-calls.js';
|
|
15
|
+
import { isCloudSessionPath } from './cloud.js';
|
|
15
16
|
/**
|
|
16
17
|
* Largest session file we will load into memory. Above this we throw a clean
|
|
17
18
|
* error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
|
|
@@ -143,7 +144,11 @@ const TRANSCRIPT_PARSERS = {
|
|
|
143
144
|
codex: (filePath) => parseCodex(filePath),
|
|
144
145
|
gemini: (filePath) => parseGemini(filePath),
|
|
145
146
|
antigravity: (filePath) => parseAntigravity(filePath),
|
|
146
|
-
opencode
|
|
147
|
+
// Cloud-captured opencode sessions are normalized JSONL (produced by the
|
|
148
|
+
// factory at capture time), NOT the local `opencode.db#<session>` SQLite
|
|
149
|
+
// composite the offline parser reads — route by whether the path is in the
|
|
150
|
+
// cloud session cache (PHNX-3845).
|
|
151
|
+
opencode: (filePath) => isCloudSessionPath(filePath) ? parseOpencodeCloud(filePath) : parseOpenCode(filePath),
|
|
147
152
|
grok: (filePath) => parseGrok(filePath),
|
|
148
153
|
rush: (filePath) => parseRush(filePath),
|
|
149
154
|
openclaw: () => [], // OpenClaw sessions don't have parseable files yet
|
|
@@ -206,7 +211,7 @@ export function detectAgent(filePath) {
|
|
|
206
211
|
return 'muse';
|
|
207
212
|
}
|
|
208
213
|
// Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
|
|
209
|
-
const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
|
|
214
|
+
const cloudMatch = filePath.match(/session\.(claude|codex|rush|opencode)\.jsonl(?:$|[?#])/);
|
|
210
215
|
if (cloudMatch)
|
|
211
216
|
return cloudMatch[1];
|
|
212
217
|
if (filePath.includes('opencode.db'))
|
|
@@ -1832,6 +1837,135 @@ export function parseOpenCode(filePath) {
|
|
|
1832
1837
|
return events;
|
|
1833
1838
|
}
|
|
1834
1839
|
// ---------------------------------------------------------------------------
|
|
1840
|
+
// OpenCode parser — cloud (normalized JSONL)
|
|
1841
|
+
// ---------------------------------------------------------------------------
|
|
1842
|
+
//
|
|
1843
|
+
// OpenCode stores sessions in a SQLite DB, so the offline path reads a
|
|
1844
|
+
// `opencode.db#<session>` composite (parseOpenCode above). The CLOUD path is
|
|
1845
|
+
// different: the factory converts that DB into a flat, normalized JSONL
|
|
1846
|
+
// transcript at capture time (prix/factory opencode-capture.ts, PHNX-3845), so
|
|
1847
|
+
// the whole cloud pipeline stays SQLite-free downstream. This parser reads that
|
|
1848
|
+
// JSONL; it's routed from TRANSCRIPT_PARSERS only when the file lives in the
|
|
1849
|
+
// cloud session cache (isCloudSessionPath).
|
|
1850
|
+
//
|
|
1851
|
+
// Each line is one of:
|
|
1852
|
+
// transcript row:
|
|
1853
|
+
// { role: "user"|"assistant", part_type: "text"|"reasoning"|"tool",
|
|
1854
|
+
// part_data: <json string>, time_created: <ms number> }
|
|
1855
|
+
// todo snapshot (at most one, emitted last):
|
|
1856
|
+
// { part_type: "todo", todos: [{content,status}], time_created: <ms> }
|
|
1857
|
+
//
|
|
1858
|
+
// The per-part logic mirrors parseOpenCode's post-query switch (and prix/api's
|
|
1859
|
+
// server-side parseOpencode) so the event stream is identical to the offline
|
|
1860
|
+
// path. NOTE: the shell tool is named `bash` on real opencode (>=1.18.x), not
|
|
1861
|
+
// `shell` — map `command` for both so a shell step reads by its command line.
|
|
1862
|
+
export function parseOpencodeCloud(filePath) {
|
|
1863
|
+
const content = safeReadSessionFile(filePath);
|
|
1864
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
1865
|
+
const events = [];
|
|
1866
|
+
for (const line of lines) {
|
|
1867
|
+
let raw;
|
|
1868
|
+
try {
|
|
1869
|
+
raw = JSON.parse(line);
|
|
1870
|
+
}
|
|
1871
|
+
catch {
|
|
1872
|
+
/* malformed JSONL line, skip */
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
const partType = typeof raw.part_type === 'string' ? raw.part_type : '';
|
|
1876
|
+
const timeMs = typeof raw.time_created === 'number'
|
|
1877
|
+
? raw.time_created
|
|
1878
|
+
: parseInt(String(raw.time_created), 10);
|
|
1879
|
+
const timestamp = Number.isFinite(timeMs)
|
|
1880
|
+
? new Date(timeMs).toISOString()
|
|
1881
|
+
: new Date().toISOString();
|
|
1882
|
+
// Todo snapshot: emit one `todo_write` tool_use so the shared enrichment
|
|
1883
|
+
// (`extractTodoProgressFromEvents`) derives `todos` the same way it does for
|
|
1884
|
+
// every other harness — the offline parser emits the identical event.
|
|
1885
|
+
if (partType === 'todo') {
|
|
1886
|
+
const todos = Array.isArray(raw.todos) ? raw.todos : [];
|
|
1887
|
+
if (todos.length) {
|
|
1888
|
+
events.push({
|
|
1889
|
+
type: 'tool_use',
|
|
1890
|
+
agent: 'opencode',
|
|
1891
|
+
timestamp,
|
|
1892
|
+
tool: 'todo_write',
|
|
1893
|
+
args: { todos },
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
let partData;
|
|
1899
|
+
try {
|
|
1900
|
+
partData = JSON.parse(typeof raw.part_data === 'string' ? raw.part_data : '');
|
|
1901
|
+
}
|
|
1902
|
+
catch {
|
|
1903
|
+
/* malformed part data, skip */
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
switch (partType) {
|
|
1907
|
+
case 'text': {
|
|
1908
|
+
const text = (partData.text || '').trim();
|
|
1909
|
+
if (text) {
|
|
1910
|
+
events.push({
|
|
1911
|
+
type: 'message',
|
|
1912
|
+
agent: 'opencode',
|
|
1913
|
+
timestamp,
|
|
1914
|
+
role: raw.role === 'user' ? 'user' : 'assistant',
|
|
1915
|
+
content: text,
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
break;
|
|
1919
|
+
}
|
|
1920
|
+
case 'reasoning': {
|
|
1921
|
+
const text = (partData.text || '').trim();
|
|
1922
|
+
if (text) {
|
|
1923
|
+
events.push({
|
|
1924
|
+
type: 'thinking',
|
|
1925
|
+
agent: 'opencode',
|
|
1926
|
+
timestamp,
|
|
1927
|
+
content: text,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
break;
|
|
1931
|
+
}
|
|
1932
|
+
case 'tool': {
|
|
1933
|
+
const toolName = partData.tool || 'unknown';
|
|
1934
|
+
const state = partData.state || {};
|
|
1935
|
+
const input = state.input || {};
|
|
1936
|
+
const output = state.output || '';
|
|
1937
|
+
const callId = typeof partData.callID === 'string' ? partData.callID : undefined;
|
|
1938
|
+
events.push({
|
|
1939
|
+
type: 'tool_use',
|
|
1940
|
+
agent: 'opencode',
|
|
1941
|
+
timestamp,
|
|
1942
|
+
tool: toolName,
|
|
1943
|
+
callId,
|
|
1944
|
+
args: input,
|
|
1945
|
+
command: toolName === 'bash' || toolName === 'shell' ? input.command : undefined,
|
|
1946
|
+
path: input.filePath || input.path || undefined,
|
|
1947
|
+
});
|
|
1948
|
+
if (state.status === 'completed' || state.status === 'error') {
|
|
1949
|
+
const outputStr = typeof output === 'string' ? output : JSON.stringify(output);
|
|
1950
|
+
events.push({
|
|
1951
|
+
type: state.status === 'error' ? 'error' : 'tool_result',
|
|
1952
|
+
agent: 'opencode',
|
|
1953
|
+
timestamp,
|
|
1954
|
+
tool: toolName,
|
|
1955
|
+
callId,
|
|
1956
|
+
success: state.status === 'completed',
|
|
1957
|
+
output: outputStr,
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
break;
|
|
1961
|
+
}
|
|
1962
|
+
// step-start / step-finish / patch / file never reach the JSONL (dropped
|
|
1963
|
+
// at capture time) — nothing to handle here.
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
return events;
|
|
1967
|
+
}
|
|
1968
|
+
// ---------------------------------------------------------------------------
|
|
1835
1969
|
// Rush parser
|
|
1836
1970
|
//
|
|
1837
1971
|
// Rush messages.jsonl format is flat: one JSON object per line with
|
|
@@ -68,7 +68,15 @@ export declare function sessionMatchesAccount(session: Pick<SessionMeta, 'agent'
|
|
|
68
68
|
export declare function inspectNativeResumeSession(session: SessionMeta, versionHome: string): NativeResumeInspection;
|
|
69
69
|
/** Keep the native conversation where possible; replay remains a separate choice. */
|
|
70
70
|
export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection, options?: SessionRecoverySelection): SessionRecoveryTarget;
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Whether recovery has conversation content to replay for this session: a
|
|
73
|
+
* non-empty transcript file, or archived content in the index for a row this
|
|
74
|
+
* device owns. A mirror digest or live registry entry is not conversation
|
|
75
|
+
* content. The picker consults this before spending a terminal tab on a pick
|
|
76
|
+
* that {@link assertRecoverableTranscript} would refuse one hop later.
|
|
77
|
+
*/
|
|
78
|
+
export declare function sessionTranscriptReadable(session: SessionMeta): boolean;
|
|
79
|
+
/** Refuse recovery for a session with nothing to replay (PHNX-4080). */
|
|
72
80
|
export declare function assertRecoverableTranscript(session: SessionMeta): void;
|
|
73
81
|
/**
|
|
74
82
|
* Resolve recovery for a durable session, reading the live account pool.
|
|
@@ -365,16 +365,26 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
365
365
|
reason: `${sourceReason(session, source, options.model ?? session.model)}; continuing with ${continueWith}`,
|
|
366
366
|
};
|
|
367
367
|
}
|
|
368
|
-
/**
|
|
369
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Whether recovery has conversation content to replay for this session: a
|
|
370
|
+
* non-empty transcript file, or archived content in the index for a row this
|
|
371
|
+
* device owns. A mirror digest or live registry entry is not conversation
|
|
372
|
+
* content. The picker consults this before spending a terminal tab on a pick
|
|
373
|
+
* that {@link assertRecoverableTranscript} would refuse one hop later.
|
|
374
|
+
*/
|
|
375
|
+
export function sessionTranscriptReadable(session) {
|
|
370
376
|
const file = splitSessionFilePath(session.filePath).container;
|
|
371
377
|
try {
|
|
372
378
|
if (file && fs.statSync(file).isFile() && fs.statSync(file).size > 0
|
|
373
379
|
&& (session.agent !== 'opencode' || parseOpenCode(session.filePath).length > 0))
|
|
374
|
-
return;
|
|
380
|
+
return true;
|
|
375
381
|
}
|
|
376
382
|
catch { /* The canonical scan already tried to repair this path. */ }
|
|
377
|
-
|
|
383
|
+
return !session.mirrorSyncedAt && !session.mirrorSource && Boolean(readSessionContent(session.id)?.trim());
|
|
384
|
+
}
|
|
385
|
+
/** Refuse recovery for a session with nothing to replay (PHNX-4080). */
|
|
386
|
+
export function assertRecoverableTranscript(session) {
|
|
387
|
+
if (sessionTranscriptReadable(session))
|
|
378
388
|
return;
|
|
379
389
|
throw new SessionRecoveryError(`Session ${session.shortId} has no readable transcript after checking its account homes and index. ` +
|
|
380
390
|
`No agent was started. Start a new conversation with: agents run ${session.agent}`);
|
|
@@ -60,6 +60,16 @@ function epochMs(value) {
|
|
|
60
60
|
export function previousSessionWatchRowKey(scope, sessionId) {
|
|
61
61
|
return createHash('sha256').update(`${scope}\0previous\0${sessionId}`).digest('base64url').slice(0, 22);
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The headline for a durable history row, on the same ladder a live row uses
|
|
65
|
+
* ({@link deriveSessionRecap}): an explicit `/rename` label, then the
|
|
66
|
+
* daemon-generated title (PHNX-3797), then the daemon-folded request headline
|
|
67
|
+
* (`request.headline` — the user's own sentence with attachment noise pulled
|
|
68
|
+
* out, PHNX-3939) when the transcript cache has one, else the raw `topic`.
|
|
69
|
+
*/
|
|
70
|
+
function previousRowTitle(session, foldedHeadline) {
|
|
71
|
+
return session.label || session.generatedTitle || foldedHeadline || session.topic || undefined;
|
|
72
|
+
}
|
|
63
73
|
/** Project one durable indexed session into the same canonical watch contract
|
|
64
74
|
* as live sessions. This is the only history backfill consumed by AGI EXT. */
|
|
65
75
|
export function toPreviousSessionWatchRow(scope, session) {
|
|
@@ -86,6 +96,7 @@ export function toPreviousSessionWatchRow(scope, session) {
|
|
|
86
96
|
// transcript-keyed cache the live merge reads (PHNX-3939), so a closed session
|
|
87
97
|
// still shows the request it was given and what the agent did — no re-parse.
|
|
88
98
|
const folded = readSessionTimelineAny(session.id);
|
|
99
|
+
const title = previousRowTitle(session, folded?.request?.headline);
|
|
89
100
|
return {
|
|
90
101
|
context: 'recent',
|
|
91
102
|
kind: session.agent,
|
|
@@ -93,14 +104,13 @@ export function toPreviousSessionWatchRow(scope, session) {
|
|
|
93
104
|
sessionId: session.id,
|
|
94
105
|
...(session.cwd ? { cwd: session.cwd } : {}),
|
|
95
106
|
...(session.project ? { project: session.project } : {}),
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
: session.topic ? { title: session.topic } : {}),
|
|
107
|
+
// Same headline ladder as a live row (`deriveSessionRecap`, PHNX-3797):
|
|
108
|
+
// `/rename` label → the daemon-generated title → `request.headline` (the
|
|
109
|
+
// user's own sentence with attachment noise pulled out, PHNX-3939) →
|
|
110
|
+
// the raw `topic`. See {@link previousRowTitle}.
|
|
111
|
+
...(session.label ? { label: session.label } : {}),
|
|
112
|
+
...(session.generatedTitle ? { generatedTitle: session.generatedTitle } : {}),
|
|
113
|
+
...(title ? { title } : {}),
|
|
104
114
|
...(session.topic ? { topic: session.topic } : {}),
|
|
105
115
|
...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
|
|
106
116
|
...(session.version ? { version: session.version } : {}),
|
|
@@ -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 {};
|