@phnx-labs/agents-cli 1.22.83 → 1.22.84
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 +39 -0
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/sessions-trace.d.ts +11 -0
- package/dist/commands/sessions-trace.js +71 -0
- package/dist/lib/accounts/connect.d.ts +6 -0
- package/dist/lib/accounts/connect.js +70 -0
- package/dist/lib/daemon-ticks.js +23 -2
- package/dist/lib/feed/activity-stream.d.ts +60 -0
- package/dist/lib/feed/activity-stream.js +271 -0
- package/dist/lib/feed/activity.d.ts +7 -0
- package/dist/lib/feed/activity.js +10 -4
- package/dist/lib/feed/watch.d.ts +9 -3
- package/dist/lib/feed/watch.js +72 -36
- package/dist/lib/fleet-shared-state.d.ts +6 -0
- package/dist/lib/session/active.d.ts +38 -4
- package/dist/lib/session/active.js +36 -4
- package/dist/lib/session/bash-command.js +10 -0
- package/dist/lib/session/db.d.ts +47 -2
- package/dist/lib/session/db.js +131 -4
- package/dist/lib/session/mirror.d.ts +5 -0
- package/dist/lib/session/mirror.js +166 -0
- package/dist/lib/session/parse.d.ts +49 -0
- package/dist/lib/session/parse.js +324 -30
- package/dist/lib/session/prompt.d.ts +93 -2
- package/dist/lib/session/prompt.js +266 -15
- package/dist/lib/session/remote/peer-stream.d.ts +47 -0
- package/dist/lib/session/remote/peer-stream.js +142 -0
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +41 -42
- package/dist/lib/session/session-cache.d.ts +9 -0
- package/dist/lib/session/session-cache.js +40 -2
- package/dist/lib/session/timeline-pass.d.ts +129 -0
- package/dist/lib/session/timeline-pass.js +323 -0
- package/dist/lib/session/timeline.d.ts +182 -0
- package/dist/lib/session/timeline.js +636 -0
- package/dist/lib/session/types.d.ts +155 -1
- package/dist/lib/summarizer/pass.d.ts +2 -0
- package/dist/lib/summarizer/pass.js +14 -1
- package/dist/lib/summarizer/summarize.d.ts +7 -0
- package/dist/lib/summarizer/summarize.js +3 -0
- package/package.json +1 -1
|
@@ -5,16 +5,6 @@
|
|
|
5
5
|
* wrapper tags, team-spawn boilerplate) so that only the real user intent
|
|
6
6
|
* remains. Used by the session picker to show a human-readable topic line.
|
|
7
7
|
*/
|
|
8
|
-
/** Patterns that cause the entire message to be skipped for topic extraction. */
|
|
9
|
-
const WHOLE_MESSAGE_SKIP_PATTERNS = [
|
|
10
|
-
/<permissions instructions>/i,
|
|
11
|
-
/<collaboration_mode>/i,
|
|
12
|
-
/^# AGENTS\.md instructions for\b/im,
|
|
13
|
-
/<local-command-caveat>/i,
|
|
14
|
-
// Slash-command invocations (e.g. /continue, /done) — actual user intent
|
|
15
|
-
// lives in the next user message, not in these wrapper tags.
|
|
16
|
-
/<command-(message|name|args)>/i,
|
|
17
|
-
];
|
|
18
8
|
/** Per-line noise patterns stripped during prompt cleaning (env context, bare paths, etc). */
|
|
19
9
|
const NOISE_LINE_PATTERNS = [
|
|
20
10
|
/^(cwd|shell|current_date|timezone|os|platform|arch|home|user)\b\s*:/i,
|
|
@@ -58,6 +48,18 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
|
|
|
58
48
|
/^\s*\[Request interrupted/i,
|
|
59
49
|
/^\s*Base directory for this skill:/i,
|
|
60
50
|
/^\s*[A-Za-z][A-Za-z-]* hook feedback:/,
|
|
51
|
+
/^\s*<\/?notification\b/i,
|
|
52
|
+
/^\s*<\/?hook_?result\b/i,
|
|
53
|
+
/^\s*\[SYSTEM NOTIFICATION/i,
|
|
54
|
+
/^\s*\[Image: original/i,
|
|
55
|
+
/^\s*Skill tool loaded instructions/i,
|
|
56
|
+
// A skill expansion the harness injects when a `/name` command resolves: the
|
|
57
|
+
// routing line the loader prepends to the skill body.
|
|
58
|
+
/^\s*\*\*`\/[\w:-]+` routes to/,
|
|
59
|
+
// The AGENTS.md preamble Claude injects ahead of a turn. `m` because it can
|
|
60
|
+
// follow other injected blocks rather than opening the message; this is the
|
|
61
|
+
// one pattern that only `extractSessionTopic` used to carry.
|
|
62
|
+
/^# AGENTS\.md instructions for\b/im,
|
|
61
63
|
];
|
|
62
64
|
/**
|
|
63
65
|
* True when a `role=user` message is harness-injected scaffolding, not a genuine
|
|
@@ -92,6 +94,25 @@ export function cleanFirstUserMessage(raw) {
|
|
|
92
94
|
const clean = unwrapUserQuery(raw);
|
|
93
95
|
return clean || undefined;
|
|
94
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* LATEST genuine user message from an already-normalized event stream.
|
|
99
|
+
*
|
|
100
|
+
* The operative request of a `/continue`d, redirected, or interrupted-and-
|
|
101
|
+
* restated session is the last thing the user said, not the first (PHNX-3939).
|
|
102
|
+
* Same rejection rules as {@link firstUserMessageFromEvents} — walking from the
|
|
103
|
+
* end so a long transcript stops at the first genuine turn it meets.
|
|
104
|
+
*/
|
|
105
|
+
export function lastUserMessageFromEvents(events) {
|
|
106
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
107
|
+
const event = events[i];
|
|
108
|
+
if (event.type !== 'message' || event.role !== 'user' || event._synthetic)
|
|
109
|
+
continue;
|
|
110
|
+
const latest = cleanFirstUserMessage(event.content);
|
|
111
|
+
if (latest)
|
|
112
|
+
return latest;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
95
116
|
/** First genuine user message from an already-normalized event stream. */
|
|
96
117
|
export function firstUserMessageFromEvents(events) {
|
|
97
118
|
for (const event of events) {
|
|
@@ -257,19 +278,249 @@ export function cleanGeneratedSessionLabel(title) {
|
|
|
257
278
|
const classified = classifyUserPrompt(trimmed);
|
|
258
279
|
return classified.kind === 'skill' ? classified.clean : trimmed;
|
|
259
280
|
}
|
|
260
|
-
/**
|
|
281
|
+
/**
|
|
282
|
+
* Extract a one-line topic from a raw user message, or undefined if the message
|
|
283
|
+
* is pure noise.
|
|
284
|
+
*
|
|
285
|
+
* Rejection is {@link isSyntheticUserMessage} — the SAME list the turn cleaner
|
|
286
|
+
* uses. This function used to consult a shorter, looser copy of it
|
|
287
|
+
* (permissions / collaboration / AGENTS.md / local-command-caveat /
|
|
288
|
+
* `<command-*>` only), which is how a `/model` echo (`<local-command-stdout>`)
|
|
289
|
+
* or a skill body ("Base directory for this skill:") became a session's topic —
|
|
290
|
+
* and then its displayed title — while `cleanFirstUserMessage` rejected the same
|
|
291
|
+
* text (PHNX-3939). Whatever is not a genuine user turn is not a topic either.
|
|
292
|
+
*
|
|
293
|
+
* The check runs twice on purpose: once on the raw message, and again after
|
|
294
|
+
* {@link cleanSessionPrompt} strips wrapper tags, since stripping can expose a
|
|
295
|
+
* marker (a skill body) that the tags were hiding.
|
|
296
|
+
*/
|
|
261
297
|
export function extractSessionTopic(raw) {
|
|
262
298
|
if (!raw.trim())
|
|
263
299
|
return undefined;
|
|
264
|
-
if (
|
|
300
|
+
if (isSyntheticUserMessage(raw))
|
|
265
301
|
return undefined;
|
|
266
|
-
}
|
|
267
302
|
const cleaned = cleanSessionPrompt(raw);
|
|
268
303
|
if (!cleaned)
|
|
269
304
|
return undefined;
|
|
270
|
-
if (
|
|
305
|
+
if (isSyntheticUserMessage(cleaned))
|
|
271
306
|
return undefined;
|
|
272
|
-
}
|
|
273
307
|
const firstLine = cleaned.split('\n').map(line => line.trim()).find(Boolean);
|
|
274
308
|
return firstLine || undefined;
|
|
275
309
|
}
|
|
310
|
+
/** `host:/abs/path.png`, `/abs/path.pdf`, or a bare `@rel/path` mention. */
|
|
311
|
+
const REQUEST_ATTACHMENT_RE = /^(?:[\w.-]+:)?\/\S.*\.(?:png|jpe?g|gif|pdf|mov|mp4)$|^@\S+$/i;
|
|
312
|
+
/**
|
|
313
|
+
* A line the user pasted out of a terminal rather than typed: a shell prompt
|
|
314
|
+
* echo (`box % `, `$ `), the CLI's own run banners, a resume hint, or the
|
|
315
|
+
* box-drawing frame of a TUI panel — a pasted `│ script (hooks, daemon, ext)`
|
|
316
|
+
* row is chrome, and it read as the request's first sentence before this.
|
|
317
|
+
*/
|
|
318
|
+
const REQUEST_ECHO_RE = /^\S+ % |^\[agents\]|^Running: |^Session [0-9a-f]{8}|^\s*Resume later:|^✔ |^\s*Week \d+% left|^\s*\$ |^[\u2500-\u257F\u2580-\u259F]/;
|
|
319
|
+
/** The full `<command-name>…</command-name> … <command-args>…</command-args>` pair. */
|
|
320
|
+
const COMMAND_WRAPPER_PAIR_RE = /<command-name>\s*(\/?[\w:-]+)\s*<\/command-name>[\s\S]*?<command-args>([\s\S]*?)<\/command-args>/;
|
|
321
|
+
/** `/name <id> [prose]` — a slash command typed with a session id argument. */
|
|
322
|
+
const SLASH_WITH_ID_RE = /^\/([\w:-]+)\s+([A-Za-z0-9_-]{8,})\s*([\s\S]*)$/;
|
|
323
|
+
/** `$name <id>` — the same invocation as the resume banner echoes it. */
|
|
324
|
+
const DOLLAR_WITH_ID_RE = /^\s*\$(\w[\w:-]*)\s+([0-9a-f-]{8,})\s*([\s\S]*)$/;
|
|
325
|
+
/** The stock body `/continue` and friends append when there is nothing else to say. */
|
|
326
|
+
const CONTINUE_FILLER = "Let's continue this session.";
|
|
327
|
+
/** A sentence ends, or continues past, a terminator — the tell that a line is prose. */
|
|
328
|
+
const SENTENCE_PUNCTUATION_RE = /[.?!]\s|[.?!]$/;
|
|
329
|
+
/**
|
|
330
|
+
* An absolute or `~`-rooted image path appearing INSIDE a line of prose. Same
|
|
331
|
+
* shape as {@link IMAGE_PATH_RE}, global so every path on the line is pulled
|
|
332
|
+
* out; matched across spaces because a CleanShot filename carries them.
|
|
333
|
+
*/
|
|
334
|
+
const INLINE_IMAGE_PATH_RE = /[\/~][^\n]*?\.(?:png|jpe?g|gif|webp|heic|bmp|svg)\b/gi;
|
|
335
|
+
/**
|
|
336
|
+
* A bare session UUID opening the turn — how `agents message <id> <text>`
|
|
337
|
+
* arrives. Full 8-4-4-4-12 only, so an ordinary word is never mistaken for one.
|
|
338
|
+
*/
|
|
339
|
+
const LEADING_SESSION_UUID_RE = /^\s*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\s+/i;
|
|
340
|
+
/** Leading tokens that mark a multi-line paste as terminal output, not a question. */
|
|
341
|
+
const PASTED_OUTPUT_HEAD_RE = /^(agents|git|gh|npm|bun|ls|cat|curl|ssh|scp)\b/;
|
|
342
|
+
/**
|
|
343
|
+
* First sentence of a block of text, flattened and capped.
|
|
344
|
+
*
|
|
345
|
+
* Leading markdown headings are skipped so the headline is the ask, not the
|
|
346
|
+
* section label. A block that then opens with markdown structure (a table row, a
|
|
347
|
+
* bullet, a numbered item) is reduced to its first LINE — a sentence-terminator
|
|
348
|
+
* scan across a table would otherwise splice two cells together. Markdown
|
|
349
|
+
* emphasis characters are dropped so a headline never renders as raw syntax. A
|
|
350
|
+
* "sentence" shorter than 12 characters is not one (an abbreviation, a version
|
|
351
|
+
* number), so the whole flattened text is kept instead.
|
|
352
|
+
*/
|
|
353
|
+
export function firstSentence(text, cap = 96) {
|
|
354
|
+
// A block that opens with markdown HEADINGS names its section before it says
|
|
355
|
+
// anything: `## Mission` / `Ship the CLI half of …` headlines as "Mission",
|
|
356
|
+
// which is the label, not the ask. Skip leading heading lines and headline the
|
|
357
|
+
// first real line under them. A heading-only block keeps the heading — there
|
|
358
|
+
// is nothing else, and inventing a line is not an option.
|
|
359
|
+
let body = text.replace(/\r/g, '').trimStart();
|
|
360
|
+
while (/^#{1,6}\s/.test(body)) {
|
|
361
|
+
const newline = body.indexOf('\n');
|
|
362
|
+
if (newline < 0)
|
|
363
|
+
break;
|
|
364
|
+
const rest = body.slice(newline + 1).trimStart();
|
|
365
|
+
if (!rest)
|
|
366
|
+
break;
|
|
367
|
+
body = rest;
|
|
368
|
+
}
|
|
369
|
+
const trimmed = body.trim() || text.trim();
|
|
370
|
+
const structured = /^([|\-*]|1\.)/.test(trimmed);
|
|
371
|
+
const source = structured ? (trimmed.split('\n')[0] ?? '') : trimmed;
|
|
372
|
+
const flat = source
|
|
373
|
+
// A leading list marker is syntax, not words.
|
|
374
|
+
.replace(/^\s*(?:[-*+]|\d+\.)\s+/, '')
|
|
375
|
+
.replace(/[*`#|_]+/g, '')
|
|
376
|
+
.replace(/\s+/g, ' ')
|
|
377
|
+
.trim();
|
|
378
|
+
const match = flat.match(/^(.+?[.!?])(\s|$)/);
|
|
379
|
+
const sentence = match && match[1].length >= 12 ? match[1] : flat;
|
|
380
|
+
return sentence.length <= cap ? sentence : `${sentence.slice(0, cap - 1).trimEnd()}…`;
|
|
381
|
+
}
|
|
382
|
+
/** A `/name <id>` invocation with no prose of its own. */
|
|
383
|
+
function bareCommandRequest(command) {
|
|
384
|
+
return { kind: 'skill', command, text: '', headline: command, attachments: [], pastedLines: 0 };
|
|
385
|
+
}
|
|
386
|
+
/** Image attachments the row carries that the prose did not already name. */
|
|
387
|
+
function attachmentsFromRow(attachments, already) {
|
|
388
|
+
if (!attachments?.length)
|
|
389
|
+
return [];
|
|
390
|
+
const seen = new Set(already.map((a) => a.name));
|
|
391
|
+
const extra = [];
|
|
392
|
+
for (const attachment of attachments) {
|
|
393
|
+
const name = (attachment.name || attachment.path || '').split(/[\\/]/).pop()?.trim();
|
|
394
|
+
if (!name || seen.has(name))
|
|
395
|
+
continue;
|
|
396
|
+
seen.add(name);
|
|
397
|
+
extra.push({ kind: attachment.mediaType?.startsWith('image/') ? 'image' : 'file', name });
|
|
398
|
+
}
|
|
399
|
+
return extra;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Split one raw user turn into prose, attachments and pasted terminal echo
|
|
403
|
+
* WITHOUT rewriting a word of it (PHNX-3939). Returns `undefined` when the turn
|
|
404
|
+
* is harness-injected scaffolding or carries nothing to show.
|
|
405
|
+
*
|
|
406
|
+
* Order matters and mirrors how a turn is actually assembled:
|
|
407
|
+
* 1. a `<user_query>` wrapper is unwrapped (Grok/Cursor);
|
|
408
|
+
* 2. a `/name <id>` or `<command-name>` invocation becomes `command`, and any
|
|
409
|
+
* prose inside `<command-args>` becomes the request itself — this is what
|
|
410
|
+
* makes a `/plan -- Wait hold on…` turn read as the user's sentence rather
|
|
411
|
+
* than as a bare command name;
|
|
412
|
+
* 3. whole-message scaffolding is rejected;
|
|
413
|
+
* 4. a multi-line paste that opens with a command and asks nothing is pasted
|
|
414
|
+
* output, not a request;
|
|
415
|
+
* 5. every remaining segment (split on ` -- ` and per line) is classified as
|
|
416
|
+
* an attachment, terminal echo, or prose.
|
|
417
|
+
*
|
|
418
|
+
* `opts.attachments` are the row's own recorded attachments, folded in so an
|
|
419
|
+
* image the harness captured but the text never named still shows as a chip.
|
|
420
|
+
*/
|
|
421
|
+
export function tidyRequest(raw, opts = {}, depth = 0) {
|
|
422
|
+
let text = (raw ?? '').trim().replace(/^"+/, '');
|
|
423
|
+
if (!text)
|
|
424
|
+
return undefined;
|
|
425
|
+
text = unwrapUserQuery(text);
|
|
426
|
+
const wrapped = COMMAND_WRAPPER_PAIR_RE.exec(text);
|
|
427
|
+
let prefix;
|
|
428
|
+
if (!wrapped && depth < 3) {
|
|
429
|
+
const slash = SLASH_WITH_ID_RE.exec(text);
|
|
430
|
+
if (slash) {
|
|
431
|
+
const command = `/${slash[1]} ${slash[2].slice(0, 8)}`;
|
|
432
|
+
const prose = slash[3].trim();
|
|
433
|
+
if (!prose || prose === CONTINUE_FILLER)
|
|
434
|
+
return bareCommandRequest(command);
|
|
435
|
+
const inner = tidyRequest(prose, opts, depth + 1);
|
|
436
|
+
if (!inner)
|
|
437
|
+
return undefined;
|
|
438
|
+
return { ...inner, command, headline: `/${slash[1]} · ${inner.headline}` };
|
|
439
|
+
}
|
|
440
|
+
const dollar = DOLLAR_WITH_ID_RE.exec(text);
|
|
441
|
+
if (dollar && !dollar[3].replace(CONTINUE_FILLER, '').trim()) {
|
|
442
|
+
return bareCommandRequest(`/${dollar[1]} ${dollar[2].slice(0, 8)}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (wrapped) {
|
|
446
|
+
const name = wrapped[1].startsWith('/') ? wrapped[1] : `/${wrapped[1]}`;
|
|
447
|
+
const args = wrapped[2].trim().replace(/^--\s*/, '');
|
|
448
|
+
if (!args || /^[0-9a-f-]{8,}$/i.test(args)) {
|
|
449
|
+
return bareCommandRequest(`${name} ${args.slice(0, 8)}`.trim());
|
|
450
|
+
}
|
|
451
|
+
text = args;
|
|
452
|
+
prefix = name;
|
|
453
|
+
}
|
|
454
|
+
if (isSyntheticUserMessage(text))
|
|
455
|
+
return undefined;
|
|
456
|
+
const lines = text.split('\n').filter((line) => line.trim());
|
|
457
|
+
if (lines.length >= 3 && PASTED_OUTPUT_HEAD_RE.test(lines[0].trim()) && !text.slice(0, 200).includes('?')) {
|
|
458
|
+
return {
|
|
459
|
+
kind: 'command',
|
|
460
|
+
text: '',
|
|
461
|
+
headline: `pasted terminal output (${lines.length} lines)`,
|
|
462
|
+
attachments: [],
|
|
463
|
+
pastedLines: lines.length,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const prose = [];
|
|
467
|
+
const attachments = [];
|
|
468
|
+
let pastedLines = 0;
|
|
469
|
+
for (const segment of text.split(/\s+--\s+/)) {
|
|
470
|
+
for (const rawLine of segment.split('\n')) {
|
|
471
|
+
const line = rawLine.trim();
|
|
472
|
+
if (!line)
|
|
473
|
+
continue;
|
|
474
|
+
if (REQUEST_ATTACHMENT_RE.test(line)) {
|
|
475
|
+
const kind = line.startsWith('@')
|
|
476
|
+
? 'dir'
|
|
477
|
+
: /\.(png|jpe?g|gif)$/i.test(line) ? 'image' : 'file';
|
|
478
|
+
const name = line.startsWith('@') ? line : (line.split(':').pop() ?? line).split('/').pop() ?? line;
|
|
479
|
+
attachments.push({ kind, name });
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
// A long line that is mostly flags and never punctuates is a pasted
|
|
483
|
+
// command invocation, not a sentence — the `agents run … --device auto
|
|
484
|
+
// --cwd … --session-id … --strategy balanced` banner a dispatch echoes.
|
|
485
|
+
const flagSoup = line.length > 80
|
|
486
|
+
&& (line.match(/\s--\w/g) ?? []).length >= 3
|
|
487
|
+
&& !SENTENCE_PUNCTUATION_RE.test(line);
|
|
488
|
+
// The `<id> --flag` shape a banner continues on, and any flag-bearing
|
|
489
|
+
// continuation once a pasted line has already been seen — so a sentence
|
|
490
|
+
// containing a dash survives when nothing was pasted.
|
|
491
|
+
const continuesPaste = pastedLines > 0 && /--\w+/.test(line) && !SENTENCE_PUNCTUATION_RE.test(line);
|
|
492
|
+
if (REQUEST_ECHO_RE.test(line) || /^[0-9a-f-]{8,}\s+--/.test(line) || flagSoup || continuesPaste) {
|
|
493
|
+
pastedLines++;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
// An image path INSIDE a prose line (a screenshot dropped mid-sentence)
|
|
497
|
+
// becomes a chip and leaves the sentence intact, rather than sitting in
|
|
498
|
+
// the middle of the request text as a wall of path.
|
|
499
|
+
const withoutImages = line.replace(INLINE_IMAGE_PATH_RE, (match) => {
|
|
500
|
+
attachments.push({ kind: 'image', name: match.split('/').pop() ?? match });
|
|
501
|
+
return '';
|
|
502
|
+
}).replace(/\s{2,}/g, ' ').trim();
|
|
503
|
+
if (withoutImages)
|
|
504
|
+
prose.push(withoutImages);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
attachments.push(...attachmentsFromRow(opts.attachments, attachments));
|
|
508
|
+
const joined = prose.join(' ').trim();
|
|
509
|
+
if (!joined && attachments.length === 0)
|
|
510
|
+
return undefined;
|
|
511
|
+
// Headline from the LINES, not the joined text: `firstSentence` skips a leading
|
|
512
|
+
// markdown heading, which it can only see while the line breaks survive. A
|
|
513
|
+
// leading bare session UUID is addressing, not the ask (`agents message
|
|
514
|
+
// <id> <text>` lands as `<uuid> Hey Claude…`), so it is dropped from the
|
|
515
|
+
// headline — the full id stays in `text`.
|
|
516
|
+
const headlineSource = prose.join('\n').replace(LEADING_SESSION_UUID_RE, '');
|
|
517
|
+
const headline = joined ? firstSentence(headlineSource || prose.join('\n'), 120) : '[image]';
|
|
518
|
+
return {
|
|
519
|
+
kind: joined ? 'text' : 'image',
|
|
520
|
+
text: joined,
|
|
521
|
+
headline: prefix ? `${prefix} · ${headline}` : headline,
|
|
522
|
+
...(prefix ? { command: prefix } : {}),
|
|
523
|
+
attachments,
|
|
524
|
+
pastedLines,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { DeviceProfile } from '../../devices/registry.js';
|
|
2
|
+
/** First reconnect delay after a failed subscription. */
|
|
3
|
+
export declare const PEER_BACKOFF_BASE_MS = 2000;
|
|
4
|
+
/** Ceiling the doubling delay saturates at. */
|
|
5
|
+
export declare const PEER_BACKOFF_CAP_MS = 60000;
|
|
6
|
+
/** Consecutive failed spawns before the peer is parked. */
|
|
7
|
+
export declare const PEER_PARK_AFTER_FAILURES = 3;
|
|
8
|
+
/** Bytes of a peer's stderr retained for the `unavailable` reason. */
|
|
9
|
+
export declare const PEER_STDERR_BYTES = 2048;
|
|
10
|
+
/** How often a parked peer re-checks the device registry for a refresh. */
|
|
11
|
+
export declare const PEER_REGISTRY_POLL_MS = 5000;
|
|
12
|
+
/**
|
|
13
|
+
* Reconnect delay for `failures` consecutive failed spawns: 0 for a healthy
|
|
14
|
+
* peer, then {@link PEER_BACKOFF_BASE_MS} doubling to {@link PEER_BACKOFF_CAP_MS}.
|
|
15
|
+
*/
|
|
16
|
+
export declare function peerBackoffDelayMs(failures: number, base?: number, cap?: number): number;
|
|
17
|
+
export interface PeerStreamOptions {
|
|
18
|
+
/** The device to subscribe to. */
|
|
19
|
+
device: DeviceProfile;
|
|
20
|
+
/** Remote command to run over ssh, already shell-quoted for the peer's OS. */
|
|
21
|
+
command: string;
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
/**
|
|
24
|
+
* One line of the peer's stdout. Return `true` when the line was a valid
|
|
25
|
+
* protocol event — that is the health signal that resets the backoff.
|
|
26
|
+
*/
|
|
27
|
+
onLine: (line: string) => boolean;
|
|
28
|
+
/** Report the peer as unavailable, with the captured reason. */
|
|
29
|
+
onUnavailable: (reason: string) => void;
|
|
30
|
+
/** Override the first backoff delay (tests). */
|
|
31
|
+
backoffBaseMs?: number;
|
|
32
|
+
/** Override the backoff ceiling (tests). */
|
|
33
|
+
backoffCapMs?: number;
|
|
34
|
+
/** Override the park threshold (tests). */
|
|
35
|
+
parkAfterFailures?: number;
|
|
36
|
+
/** Override the ssh binary (tests). */
|
|
37
|
+
sshBin?: string;
|
|
38
|
+
/** Override the parked peer's registry re-check cadence (tests). */
|
|
39
|
+
registryPollMs?: number;
|
|
40
|
+
/** Override the registry file a parked peer waits on (tests). */
|
|
41
|
+
registryPath?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Hold one peer subscription open for the life of the signal, reconnecting with
|
|
45
|
+
* backoff. Resolves when the signal aborts or the peer cannot be addressed.
|
|
46
|
+
*/
|
|
47
|
+
export declare function streamFromPeer(options: PeerStreamOptions): Promise<void>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One peer's `--local` watch subscription, with connection hygiene.
|
|
3
|
+
*
|
|
4
|
+
* Both fleet fan-outs (`watchFleetSessions`, `watchFleetFeed`) subscribe to
|
|
5
|
+
* every dialable device with a long-lived `ssh <peer> agents … watch --json
|
|
6
|
+
* --local`. Both used to respawn that child on a bare 2 s timer with the
|
|
7
|
+
* child's stderr set to `'ignore'`, so an offline peer — and `isDialableDevice`
|
|
8
|
+
* deliberately never excludes one — got a fresh ssh every ConnectTimeout + 2 s
|
|
9
|
+
* for the whole life of the watcher, with the reason discarded. That is the
|
|
10
|
+
* ssh-child churn the 2026-09-03 incident reported.
|
|
11
|
+
*
|
|
12
|
+
* This module is the single implementation both fan-outs call:
|
|
13
|
+
*
|
|
14
|
+
* - exponential backoff per peer, {@link PEER_BACKOFF_BASE_MS} doubling to
|
|
15
|
+
* {@link PEER_BACKOFF_CAP_MS}, reset the moment the peer delivers a healthy
|
|
16
|
+
* protocol event;
|
|
17
|
+
* - the child's stderr captured (bounded to {@link PEER_STDERR_BYTES}) and
|
|
18
|
+
* surfaced as the `unavailable` reason instead of being thrown away;
|
|
19
|
+
* - a peer that fails {@link PEER_PARK_AFTER_FAILURES} spawns in a row parked —
|
|
20
|
+
* it stops the reconnect cycle and re-dials only when the device registry
|
|
21
|
+
* changes or the capped backoff elapses;
|
|
22
|
+
* - abort listeners removed per iteration, so a watcher open for hours does not
|
|
23
|
+
* accumulate one per reconnect on the caller's AbortSignal.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import { createInterface } from 'node:readline';
|
|
28
|
+
import { deviceIdentityArgs, sshTargetFor } from '../../devices/connect.js';
|
|
29
|
+
import { SSH_OPTS, controlOpts } from '../../ssh-exec.js';
|
|
30
|
+
import { getDevicesRegistryPath } from '../../state.js';
|
|
31
|
+
/** First reconnect delay after a failed subscription. */
|
|
32
|
+
export const PEER_BACKOFF_BASE_MS = 2_000;
|
|
33
|
+
/** Ceiling the doubling delay saturates at. */
|
|
34
|
+
export const PEER_BACKOFF_CAP_MS = 60_000;
|
|
35
|
+
/** Consecutive failed spawns before the peer is parked. */
|
|
36
|
+
export const PEER_PARK_AFTER_FAILURES = 3;
|
|
37
|
+
/** Bytes of a peer's stderr retained for the `unavailable` reason. */
|
|
38
|
+
export const PEER_STDERR_BYTES = 2_048;
|
|
39
|
+
/** How often a parked peer re-checks the device registry for a refresh. */
|
|
40
|
+
export const PEER_REGISTRY_POLL_MS = 5_000;
|
|
41
|
+
/**
|
|
42
|
+
* Reconnect delay for `failures` consecutive failed spawns: 0 for a healthy
|
|
43
|
+
* peer, then {@link PEER_BACKOFF_BASE_MS} doubling to {@link PEER_BACKOFF_CAP_MS}.
|
|
44
|
+
*/
|
|
45
|
+
export function peerBackoffDelayMs(failures, base = PEER_BACKOFF_BASE_MS, cap = PEER_BACKOFF_CAP_MS) {
|
|
46
|
+
if (failures <= 0)
|
|
47
|
+
return 0;
|
|
48
|
+
return Math.min(cap, base * 2 ** (failures - 1));
|
|
49
|
+
}
|
|
50
|
+
/** Bounded tail of a child's stderr, kept for the unavailable reason. */
|
|
51
|
+
function stderrTail(chunks, next, limit) {
|
|
52
|
+
const joined = chunks + next;
|
|
53
|
+
return joined.length > limit ? joined.slice(joined.length - limit) : joined;
|
|
54
|
+
}
|
|
55
|
+
/** Collapse captured stderr to one quotable line. */
|
|
56
|
+
function reasonFor(exit, stderr) {
|
|
57
|
+
const detail = stderr.split('\n').map((line) => line.trim()).filter(Boolean).pop();
|
|
58
|
+
return detail ? `${exit}: ${detail}` : exit;
|
|
59
|
+
}
|
|
60
|
+
/** Await a delay, a device-registry change, or abort — whichever comes first. */
|
|
61
|
+
async function parkedWait(options, delayMs) {
|
|
62
|
+
const registry = options.registryPath ?? getDevicesRegistryPath();
|
|
63
|
+
const pollMs = options.registryPollMs ?? PEER_REGISTRY_POLL_MS;
|
|
64
|
+
const stampOf = () => {
|
|
65
|
+
try {
|
|
66
|
+
const st = fs.statSync(registry);
|
|
67
|
+
return `${st.size}:${st.mtimeMs}`;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const before = stampOf();
|
|
74
|
+
await new Promise((resolve) => {
|
|
75
|
+
let done = false;
|
|
76
|
+
const finish = () => {
|
|
77
|
+
if (done)
|
|
78
|
+
return;
|
|
79
|
+
done = true;
|
|
80
|
+
clearTimeout(deadline);
|
|
81
|
+
clearInterval(poll);
|
|
82
|
+
options.signal.removeEventListener('abort', finish);
|
|
83
|
+
resolve();
|
|
84
|
+
};
|
|
85
|
+
const deadline = setTimeout(finish, delayMs);
|
|
86
|
+
const poll = setInterval(() => { if (stampOf() !== before)
|
|
87
|
+
finish(); }, Math.min(pollMs, Math.max(delayMs, 1)));
|
|
88
|
+
options.signal.addEventListener('abort', finish, { once: true });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Hold one peer subscription open for the life of the signal, reconnecting with
|
|
93
|
+
* backoff. Resolves when the signal aborts or the peer cannot be addressed.
|
|
94
|
+
*/
|
|
95
|
+
export async function streamFromPeer(options) {
|
|
96
|
+
const parkAfter = options.parkAfterFailures ?? PEER_PARK_AFTER_FAILURES;
|
|
97
|
+
let failures = 0;
|
|
98
|
+
while (!options.signal.aborted) {
|
|
99
|
+
let target;
|
|
100
|
+
try {
|
|
101
|
+
target = sshTargetFor(options.device);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
// No address at all is not a transient failure: retrying cannot fix it.
|
|
105
|
+
options.onUnavailable(error instanceof Error ? error.message : String(error));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const child = spawn(options.sshBin ?? 'ssh', [
|
|
109
|
+
...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(options.device), target, options.command,
|
|
110
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
111
|
+
const stop = () => child.kill('SIGTERM');
|
|
112
|
+
options.signal.addEventListener('abort', stop, { once: true });
|
|
113
|
+
let stderr = '';
|
|
114
|
+
child.stderr?.setEncoding('utf-8');
|
|
115
|
+
child.stderr?.on('data', (chunk) => { stderr = stderrTail(stderr, chunk, PEER_STDERR_BYTES); });
|
|
116
|
+
const reader = createInterface({ input: child.stdout });
|
|
117
|
+
reader.on('line', (line) => {
|
|
118
|
+
// A peer that is delivering protocol is healthy, so the next disconnect
|
|
119
|
+
// starts the backoff over rather than inheriting an old failure streak.
|
|
120
|
+
if (options.onLine(line))
|
|
121
|
+
failures = 0;
|
|
122
|
+
});
|
|
123
|
+
const code = await new Promise((resolve) => {
|
|
124
|
+
child.once('error', () => resolve(null));
|
|
125
|
+
child.once('close', resolve);
|
|
126
|
+
});
|
|
127
|
+
reader.close();
|
|
128
|
+
// Removed per iteration: a watcher open for hours would otherwise leak one
|
|
129
|
+
// listener per reconnect onto the caller's signal.
|
|
130
|
+
options.signal.removeEventListener('abort', stop);
|
|
131
|
+
if (options.signal.aborted)
|
|
132
|
+
return;
|
|
133
|
+
failures += 1;
|
|
134
|
+
const exit = code == null ? 'ssh failed' : `ssh exited ${code}`;
|
|
135
|
+
const parked = failures >= parkAfter;
|
|
136
|
+
const delay = peerBackoffDelayMs(failures, options.backoffBaseMs, options.backoffCapMs);
|
|
137
|
+
options.onUnavailable(parked
|
|
138
|
+
? `${reasonFor(exit, stderr)} — parked after ${failures} failed connections, retrying in ${Math.round(delay / 1000)}s or on a device refresh`
|
|
139
|
+
: reasonFor(exit, stderr));
|
|
140
|
+
await parkedWait(options, delay);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ActiveSession } from '../active.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type SessionMeta } from '../types.js';
|
|
3
3
|
import { readActiveSessionsCache } from '../session-cache.js';
|
|
4
4
|
export declare const SESSION_WATCH_VERSION: 1;
|
|
5
5
|
export declare const SESSION_WATCH_HEARTBEAT_MS = 15000;
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import { createInterface } from 'node:readline';
|
|
4
2
|
import * as fs from 'node:fs';
|
|
5
3
|
import * as path from 'node:path';
|
|
6
4
|
import { loadDevices, isDialableDevice } from '../../devices/registry.js';
|
|
7
|
-
import { deviceIdentityArgs, sshTargetFor } from '../../devices/connect.js';
|
|
8
5
|
import { machineId, normalizeHost } from '../../machine-id.js';
|
|
9
|
-
import {
|
|
6
|
+
import { shellQuote } from '../../ssh-exec.js';
|
|
7
|
+
import { streamFromPeer } from './peer-stream.js';
|
|
10
8
|
import { buildWindowsAgentsCommand, remoteShellFor } from '../../hosts/remote-cmd.js';
|
|
11
9
|
import { isReapableOrphan } from '../active.js';
|
|
12
|
-
import { querySessions, readSessionSummaryAny } from '../db.js';
|
|
10
|
+
import { querySessions, readSessionSummaryAny, readSessionTimelineAny } from '../db.js';
|
|
13
11
|
import { linearIssueUrl } from '../linear.js';
|
|
14
12
|
import { sessionAgentSupportsResume } from '../recovery.js';
|
|
13
|
+
import { SESSION_AGENTS } from '../types.js';
|
|
15
14
|
import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, noteActiveSessionsJournalReader, resolveStreamSummaryState, } from '../session-cache.js';
|
|
16
15
|
export const SESSION_WATCH_VERSION = 1;
|
|
17
16
|
export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
|
|
@@ -83,6 +82,10 @@ export function toPreviousSessionWatchRow(scope, session) {
|
|
|
83
82
|
summaryState: session.summaryState,
|
|
84
83
|
}
|
|
85
84
|
: readSessionSummaryAny(session.id);
|
|
85
|
+
// Project the daemon-folded timeline onto the history row from the same
|
|
86
|
+
// transcript-keyed cache the live merge reads (PHNX-3939), so a closed session
|
|
87
|
+
// still shows the request it was given and what the agent did — no re-parse.
|
|
88
|
+
const folded = readSessionTimelineAny(session.id);
|
|
86
89
|
return {
|
|
87
90
|
context: 'recent',
|
|
88
91
|
kind: session.agent,
|
|
@@ -90,7 +93,14 @@ export function toPreviousSessionWatchRow(scope, session) {
|
|
|
90
93
|
sessionId: session.id,
|
|
91
94
|
...(session.cwd ? { cwd: session.cwd } : {}),
|
|
92
95
|
...(session.project ? { project: session.project } : {}),
|
|
93
|
-
|
|
96
|
+
// `request.headline` is the user's own sentence with the attachment noise
|
|
97
|
+
// pulled out, so it beats the raw `topic` for the row title — a `/model` echo
|
|
98
|
+
// or a skill body can no longer become a session's name (PHNX-3939).
|
|
99
|
+
...(session.label
|
|
100
|
+
? { label: session.label, title: session.label }
|
|
101
|
+
: folded?.request?.headline
|
|
102
|
+
? { title: folded.request.headline }
|
|
103
|
+
: session.topic ? { title: session.topic } : {}),
|
|
94
104
|
...(session.topic ? { topic: session.topic } : {}),
|
|
95
105
|
...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
|
|
96
106
|
...(session.version ? { version: session.version } : {}),
|
|
@@ -108,6 +118,9 @@ export function toPreviousSessionWatchRow(scope, session) {
|
|
|
108
118
|
...(summary?.checkpoints ? { checkpoints: summary.checkpoints } : {}),
|
|
109
119
|
...(summary?.summaryChecklist ? { summaryChecklist: summary.summaryChecklist } : {}),
|
|
110
120
|
summaryState: resolveStreamSummaryState(summary?.summaryState),
|
|
121
|
+
...(folded?.request ? { request: folded.request } : {}),
|
|
122
|
+
...(folded?.timeline ? { timeline: folded.timeline } : {}),
|
|
123
|
+
...(folded?.files ? { files: folded.files } : {}),
|
|
111
124
|
startedAtMs,
|
|
112
125
|
lastActivityMs,
|
|
113
126
|
status: 'closed',
|
|
@@ -285,7 +298,12 @@ export function readPreviousSessionsForWatch(scope) {
|
|
|
285
298
|
try {
|
|
286
299
|
return querySessions({
|
|
287
300
|
machine: normalizeHost(scope),
|
|
288
|
-
|
|
301
|
+
// Every harness that writes a discoverable transcript. The list used to be
|
|
302
|
+
// four, so a Kimi/Grok/Cursor/Droid session simply never appeared as a
|
|
303
|
+
// Previous row — and after PHNX-3939 it would also have been the only rows
|
|
304
|
+
// with no request/timeline. Kept explicit (not `SESSION_AGENTS`) so
|
|
305
|
+
// `openclaw`, which has no transcript to project, stays out.
|
|
306
|
+
agents: SESSION_AGENTS.filter((agent) => agent !== 'openclaw'),
|
|
289
307
|
sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000,
|
|
290
308
|
excludeTeamOrigin: true,
|
|
291
309
|
})
|
|
@@ -406,47 +424,28 @@ export async function watchFleetSessions(options) {
|
|
|
406
424
|
const peers = Object.values(devices).filter((device) => isDialableDevice(device)
|
|
407
425
|
&& normalizeHost(device.name) !== self
|
|
408
426
|
&& ['windows', 'linux', 'macos'].includes(device.platform));
|
|
409
|
-
const reconnectMs = options.reconnectMs ?? 2_000;
|
|
410
427
|
const peerTasks = peers.map(async (device) => {
|
|
411
428
|
const scope = normalizeHost(device.name);
|
|
412
429
|
const state = new SessionWatchState();
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
options.emit(state.scope(scope, 'unavailable', error instanceof Error ? error.message : String(error)));
|
|
420
|
-
break;
|
|
421
|
-
}
|
|
422
|
-
const child = spawn('ssh', [
|
|
423
|
-
...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(device), target, remoteWatchCommand(device.platform),
|
|
424
|
-
], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
425
|
-
const stop = () => child.kill('SIGTERM');
|
|
426
|
-
options.signal.addEventListener('abort', stop, { once: true });
|
|
427
|
-
const reader = createInterface({ input: child.stdout });
|
|
428
|
-
reader.on('line', (line) => {
|
|
430
|
+
await streamFromPeer({
|
|
431
|
+
device,
|
|
432
|
+
signal: options.signal,
|
|
433
|
+
command: remoteWatchCommand(device.platform),
|
|
434
|
+
backoffBaseMs: options.reconnectMs,
|
|
435
|
+
onLine: (line) => {
|
|
429
436
|
try {
|
|
430
437
|
const event = JSON.parse(line);
|
|
431
|
-
if (event.version
|
|
432
|
-
|
|
438
|
+
if (event.version !== SESSION_WATCH_VERSION || typeof event.sequence !== 'number')
|
|
439
|
+
return false;
|
|
440
|
+
options.emit(event);
|
|
441
|
+
return true;
|
|
433
442
|
}
|
|
434
|
-
catch {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
reader.close();
|
|
441
|
-
options.signal.removeEventListener('abort', stop);
|
|
442
|
-
if (options.signal.aborted)
|
|
443
|
-
break;
|
|
444
|
-
options.emit(state.scope(scope, 'unavailable', code === null ? 'ssh failed' : `ssh exited ${code}`));
|
|
445
|
-
await new Promise((resolve) => {
|
|
446
|
-
const timer = setTimeout(resolve, reconnectMs);
|
|
447
|
-
options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
448
|
-
});
|
|
449
|
-
}
|
|
443
|
+
catch {
|
|
444
|
+
return false; /* incomplete/non-protocol peer output is not state */
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
onUnavailable: (reason) => options.emit(state.scope(scope, 'unavailable', reason)),
|
|
448
|
+
});
|
|
450
449
|
});
|
|
451
450
|
await Promise.all([local, ...peerTasks]);
|
|
452
451
|
}
|
|
@@ -190,6 +190,15 @@ export declare function mergeSessionSummary(s: ActiveSession): ActiveSession;
|
|
|
190
190
|
* the readiness flag is read on the read path, and it is memoized (PHNX-3939).
|
|
191
191
|
*/
|
|
192
192
|
export declare function resolveStreamSummaryState(current: import('./types.js').SummaryState | undefined, nowMs?: number): import('./types.js').SummaryState;
|
|
193
|
+
/**
|
|
194
|
+
* Merge the daemon-folded timeline (PHNX-3939) onto a live row from the
|
|
195
|
+
* transcript-keyed `session_timelines` cache. Sibling of
|
|
196
|
+
* {@link mergeSessionSummary} and the same contract: one indexed by-id read of
|
|
197
|
+
* the BOUNDED projection (never the fold's resume state), never a parse, never
|
|
198
|
+
* a model call. `runTimelinePass` in the daemon tick is the only producer.
|
|
199
|
+
* Best-effort — a DB error leaves the row untouched.
|
|
200
|
+
*/
|
|
201
|
+
export declare function mergeSessionTimeline(s: ActiveSession): ActiveSession;
|
|
193
202
|
export declare function applyImmutableMemo(s: ActiveSession): ActiveSession;
|
|
194
203
|
export interface LoadLocalActiveSessionsOptions {
|
|
195
204
|
/** Skip the cache and re-gather (the force-refresh path). */
|