@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
|
@@ -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
|
+
}
|
|
@@ -10,7 +10,7 @@ export declare const TOOL_CHANGED_MAX_CALLS = 10000;
|
|
|
10
10
|
export declare const TOOL_INDEX_LIMIT_ORDINAL: number;
|
|
11
11
|
export declare const TOOL_TEXT_PROCESSING_MAX_BYTES: number;
|
|
12
12
|
export declare const TOOL_SHELL_PARSE_MAX_BYTES: number;
|
|
13
|
-
export declare const TOOL_INDEX_VERSION =
|
|
13
|
+
export declare const TOOL_INDEX_VERSION = 9;
|
|
14
14
|
export type ToolCallOutcome = 'ok' | 'error' | 'unknown';
|
|
15
15
|
export interface IndexedToolCall {
|
|
16
16
|
ordinal: number;
|
|
@@ -12,10 +12,12 @@ export const TOOL_CHANGED_MAX_CALLS = 10_000;
|
|
|
12
12
|
export const TOOL_INDEX_LIMIT_ORDINAL = Number.MAX_SAFE_INTEGER;
|
|
13
13
|
export const TOOL_TEXT_PROCESSING_MAX_BYTES = 64 * 1024;
|
|
14
14
|
export const TOOL_SHELL_PARSE_MAX_BYTES = 64 * 1024;
|
|
15
|
-
// Bumped to
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
15
|
+
// Bumped to 9 for Codex tool-outcome classification (PHNX-3761): Codex results
|
|
16
|
+
// arrive as a string or input_text[] rather than an object, so every one used to
|
|
17
|
+
// store as 'unknown' and error rates read 0%. A re-index re-derives outcome for
|
|
18
|
+
// rows stored by an older extractor.
|
|
19
|
+
// (8 added the per-call end timestamp, PHNX-3437.)
|
|
20
|
+
export const TOOL_INDEX_VERSION = 9;
|
|
19
21
|
const BASE64_BLOCK = /(?:[A-Za-z0-9+/]{256,}={0,2})/g;
|
|
20
22
|
const SECRET_FIELD = /(?:token|secret|password|authorization|cookie|api[_-]?key|private[_-]?key)$/i;
|
|
21
23
|
const KNOWN_SECRET_VALUES = knownSecretValuesFromEnv();
|
|
@@ -258,9 +260,37 @@ function structuredString(source, name) {
|
|
|
258
260
|
const value = structuredField(source, name);
|
|
259
261
|
return typeof value === 'string' ? value : undefined;
|
|
260
262
|
}
|
|
263
|
+
// Codex tool results arrive as a plain string or an array of {type:'input_text',
|
|
264
|
+
// text} blocks (never a plain object), so the text lives across the blocks and
|
|
265
|
+
// the only outcome signal is the leading "Script completed"/"Script failed"
|
|
266
|
+
// marker the exec harness prefixes onto the first block.
|
|
267
|
+
function textSegments(value) {
|
|
268
|
+
if (typeof value === 'string')
|
|
269
|
+
return [value];
|
|
270
|
+
if (Array.isArray(value)) {
|
|
271
|
+
return value
|
|
272
|
+
.map((block) => block && typeof block === 'object' && typeof block.text === 'string'
|
|
273
|
+
? block.text
|
|
274
|
+
: '')
|
|
275
|
+
.filter((text) => text.length > 0);
|
|
276
|
+
}
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
function codexScriptOutcome(value) {
|
|
280
|
+
const lead = textSegments(value)[0]?.trimStart();
|
|
281
|
+
if (!lead)
|
|
282
|
+
return undefined;
|
|
283
|
+
if (lead.startsWith('Script failed'))
|
|
284
|
+
return 'error';
|
|
285
|
+
if (lead.startsWith('Script completed'))
|
|
286
|
+
return 'ok';
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
261
289
|
function resultText(value) {
|
|
262
290
|
if (typeof value === 'string')
|
|
263
291
|
return value;
|
|
292
|
+
if (Array.isArray(value))
|
|
293
|
+
return textSegments(value).join('\n');
|
|
264
294
|
const object = resultObject(value);
|
|
265
295
|
if (object) {
|
|
266
296
|
for (const key of ['output', 'stdout', 'content', 'text', 'message']) {
|
|
@@ -280,9 +310,14 @@ export function structuredToolResult(value) {
|
|
|
280
310
|
const structured = resultObject(value);
|
|
281
311
|
const success = structuredField(structured, 'success');
|
|
282
312
|
const isError = success === false || structuredField(structured, 'is_error') === true;
|
|
313
|
+
const outcome = isError
|
|
314
|
+
? 'error'
|
|
315
|
+
: success === true
|
|
316
|
+
? 'ok'
|
|
317
|
+
: codexScriptOutcome(value) ?? 'unknown';
|
|
283
318
|
return {
|
|
284
319
|
text: resultText(value),
|
|
285
|
-
outcome
|
|
320
|
+
outcome,
|
|
286
321
|
exitCode: structuredNumber(structured, 'exit_code'),
|
|
287
322
|
statusCode: structuredNumber(structured, 'status_code'),
|
|
288
323
|
errorCode: structuredString(structured, 'error_code'),
|
|
@@ -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
|
|
@@ -89,6 +89,29 @@ export declare function phoenixIdBaseForDeploy(opts?: {
|
|
|
89
89
|
baseUrl?: string;
|
|
90
90
|
domain?: string;
|
|
91
91
|
}): string | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Managed collaboration backend config to bind on a managed Worker deploy
|
|
94
|
+
* (PHNX-3835): `PRIX_ARTIFACT_COLLAB_BASE` (the Prix artifact-collaboration API
|
|
95
|
+
* base) and `ARTIFACT_COLLAB_SERVICE_TOKEN` (the service credential the Worker
|
|
96
|
+
* sends as `Authorization: Bearer`, NEVER surfaced to the browser). Both are read
|
|
97
|
+
* from the deploy environment.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately dormant-by-default: the two secrets are applied ONLY when BOTH are
|
|
100
|
+
* present. Until the Prix API counterpart is live an operator leaves them unset,
|
|
101
|
+
* and the Worker fails `/__collab` closed (404) with the artifact page GET
|
|
102
|
+
* unaffected. A pure-BYO endpoint never gets them (it has no Phoenix identity).
|
|
103
|
+
* Unlike `phoenixIdBaseForDeploy` this never throws on absence — collaboration is
|
|
104
|
+
* additive, not required for a working managed deploy.
|
|
105
|
+
*/
|
|
106
|
+
export declare function collabConfigForDeploy(opts?: {
|
|
107
|
+
managed?: boolean;
|
|
108
|
+
}, cfg?: {
|
|
109
|
+
baseUrl?: string;
|
|
110
|
+
domain?: string;
|
|
111
|
+
}): {
|
|
112
|
+
collabBase?: string;
|
|
113
|
+
collabServiceToken?: string;
|
|
114
|
+
};
|
|
92
115
|
/**
|
|
93
116
|
* Pick the principal via the shared selection policy. Signed-in AND no explicit
|
|
94
117
|
* BYO override → managed; otherwise BYO.
|
|
@@ -84,6 +84,30 @@ export function phoenixIdBaseForDeploy(opts = {}, cfg) {
|
|
|
84
84
|
}
|
|
85
85
|
return base;
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Managed collaboration backend config to bind on a managed Worker deploy
|
|
89
|
+
* (PHNX-3835): `PRIX_ARTIFACT_COLLAB_BASE` (the Prix artifact-collaboration API
|
|
90
|
+
* base) and `ARTIFACT_COLLAB_SERVICE_TOKEN` (the service credential the Worker
|
|
91
|
+
* sends as `Authorization: Bearer`, NEVER surfaced to the browser). Both are read
|
|
92
|
+
* from the deploy environment.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately dormant-by-default: the two secrets are applied ONLY when BOTH are
|
|
95
|
+
* present. Until the Prix API counterpart is live an operator leaves them unset,
|
|
96
|
+
* and the Worker fails `/__collab` closed (404) with the artifact page GET
|
|
97
|
+
* unaffected. A pure-BYO endpoint never gets them (it has no Phoenix identity).
|
|
98
|
+
* Unlike `phoenixIdBaseForDeploy` this never throws on absence — collaboration is
|
|
99
|
+
* additive, not required for a working managed deploy.
|
|
100
|
+
*/
|
|
101
|
+
export function collabConfigForDeploy(opts = {}, cfg) {
|
|
102
|
+
const managed = opts.managed === true || (cfg != null && isManagedShareEndpoint(cfg));
|
|
103
|
+
if (!managed)
|
|
104
|
+
return {};
|
|
105
|
+
const base = (process.env.PRIX_ARTIFACT_COLLAB_BASE || '').replace(/\/+$/, '').trim();
|
|
106
|
+
const token = (process.env.ARTIFACT_COLLAB_SERVICE_TOKEN || '').trim();
|
|
107
|
+
if (!base || !token)
|
|
108
|
+
return {};
|
|
109
|
+
return { collabBase: base, collabServiceToken: token };
|
|
110
|
+
}
|
|
87
111
|
/**
|
|
88
112
|
* The share surface's BYO-override signals: an explicit `--byo`, a
|
|
89
113
|
* caller-supplied static write token, or `AGENTS_SHARE_BACKEND=byo`. Detecting
|
|
@@ -64,6 +64,10 @@ export interface UpdateWorkerResult {
|
|
|
64
64
|
}
|
|
65
65
|
/** Cloudflare `secret_text` name the Worker reads as `env.PHOENIX_ID_BASE`. */
|
|
66
66
|
export declare const WORKER_PHOENIX_ID_BASE_SECRET = "PHOENIX_ID_BASE";
|
|
67
|
+
/** Cloudflare `secret_text` name the Worker reads as `env.PRIX_ARTIFACT_COLLAB_BASE`. */
|
|
68
|
+
export declare const WORKER_COLLAB_BASE_SECRET = "PRIX_ARTIFACT_COLLAB_BASE";
|
|
69
|
+
/** Cloudflare `secret_text` name the Worker reads as `env.ARTIFACT_COLLAB_SERVICE_TOKEN`. */
|
|
70
|
+
export declare const WORKER_COLLAB_SERVICE_TOKEN_SECRET = "ARTIFACT_COLLAB_SERVICE_TOKEN";
|
|
67
71
|
export type UpdateWorkerOpts = ProvisionOptions & {
|
|
68
72
|
force?: boolean;
|
|
69
73
|
/**
|
|
@@ -73,6 +77,14 @@ export type UpdateWorkerOpts = ProvisionOptions & {
|
|
|
73
77
|
* fails loud — do not pass a blank to "skip".
|
|
74
78
|
*/
|
|
75
79
|
phoenixIdBase?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Managed collaboration backend (PHNX-3835). BOTH must be present to enable
|
|
82
|
+
* `/__collab`; pass neither to leave the surface disabled (the Worker fails it
|
|
83
|
+
* closed). The service token rides Cloudflare's Secrets API, never rendered
|
|
84
|
+
* HTML or browser JS. Applied like `phoenixIdBase` — only when defined.
|
|
85
|
+
*/
|
|
86
|
+
collabBase?: string;
|
|
87
|
+
collabServiceToken?: string;
|
|
76
88
|
};
|
|
77
89
|
/**
|
|
78
90
|
* Re-deploy the Worker script against an ALREADY-provisioned endpoint (same
|
|
@@ -107,6 +107,10 @@ export function hashWorkerScript(script) {
|
|
|
107
107
|
}
|
|
108
108
|
/** Cloudflare `secret_text` name the Worker reads as `env.PHOENIX_ID_BASE`. */
|
|
109
109
|
export const WORKER_PHOENIX_ID_BASE_SECRET = 'PHOENIX_ID_BASE';
|
|
110
|
+
/** Cloudflare `secret_text` name the Worker reads as `env.PRIX_ARTIFACT_COLLAB_BASE`. */
|
|
111
|
+
export const WORKER_COLLAB_BASE_SECRET = 'PRIX_ARTIFACT_COLLAB_BASE';
|
|
112
|
+
/** Cloudflare `secret_text` name the Worker reads as `env.ARTIFACT_COLLAB_SERVICE_TOKEN`. */
|
|
113
|
+
export const WORKER_COLLAB_SERVICE_TOKEN_SECRET = 'ARTIFACT_COLLAB_SERVICE_TOKEN';
|
|
110
114
|
/**
|
|
111
115
|
* Re-deploy the Worker script against an ALREADY-provisioned endpoint (same
|
|
112
116
|
* account/worker/bucket `deployWorker` was first called with) and idempotently
|
|
@@ -145,6 +149,7 @@ export async function updateWorker(apiToken, accountId, workerName, bucketName,
|
|
|
145
149
|
if (opts.phoenixIdBase !== undefined) {
|
|
146
150
|
await applyPhoenixIdBaseSecret(apiToken, accountId, workerName, opts.phoenixIdBase, opts);
|
|
147
151
|
}
|
|
152
|
+
await applyCollabSecrets(apiToken, accountId, workerName, opts);
|
|
148
153
|
return { templateHash, skipped: true };
|
|
149
154
|
}
|
|
150
155
|
await deployWorker(apiToken, accountId, workerName, worker, bucketName, opts);
|
|
@@ -168,8 +173,33 @@ export async function updateWorker(apiToken, accountId, workerName, bucketName,
|
|
|
168
173
|
throw new Error(`Worker deployed but PHOENIX_ID_BASE failed to re-apply — Phoenix-bearer publishes will 401 until you re-run \`agents artifacts share update\`. (${detail})`);
|
|
169
174
|
}
|
|
170
175
|
}
|
|
176
|
+
// The script upload wiped bindings/secrets (see JSDoc above), so re-apply the
|
|
177
|
+
// collaboration secrets too — but only when the caller provided them. An
|
|
178
|
+
// ordinary deploy with collaboration still dormant passes neither and the
|
|
179
|
+
// Worker keeps failing `/__collab` closed.
|
|
180
|
+
try {
|
|
181
|
+
await applyCollabSecrets(apiToken, accountId, workerName, opts);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
185
|
+
throw new Error(`Worker deployed but the collaboration secrets failed to re-apply — \`/__collab\` stays disabled until you re-run \`agents artifacts share update\`. (${detail})`);
|
|
186
|
+
}
|
|
171
187
|
return { templateHash, skipped: false };
|
|
172
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Bind the managed collaboration secrets (PHNX-3835) — both or neither. A no-op
|
|
191
|
+
* when the caller passed neither (collaboration dormant), so a plain managed
|
|
192
|
+
* deploy never touches them; fails loud on a half-config (one without the other).
|
|
193
|
+
*/
|
|
194
|
+
async function applyCollabSecrets(apiToken, accountId, workerName, opts) {
|
|
195
|
+
if (opts.collabBase === undefined && opts.collabServiceToken === undefined)
|
|
196
|
+
return;
|
|
197
|
+
if (opts.collabBase === undefined || opts.collabServiceToken === undefined) {
|
|
198
|
+
throw new Error('Managed collaboration needs BOTH PRIX_ARTIFACT_COLLAB_BASE and ARTIFACT_COLLAB_SERVICE_TOKEN — set both or neither.');
|
|
199
|
+
}
|
|
200
|
+
await putWorkerSecret(apiToken, accountId, workerName, WORKER_COLLAB_BASE_SECRET, opts.collabBase, opts);
|
|
201
|
+
await putWorkerSecret(apiToken, accountId, workerName, WORKER_COLLAB_SERVICE_TOKEN_SECRET, opts.collabServiceToken, opts);
|
|
202
|
+
}
|
|
173
203
|
/** Add/update a secret_text binding using Cloudflare's Workers Secrets API. */
|
|
174
204
|
export async function putWorkerSecret(apiToken, accountId, workerName, name, text, opts = {}) {
|
|
175
205
|
const request = opts.request ?? defaultCloudflareRequester;
|