@phnx-labs/agents-cli 1.20.29 → 1.20.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/inspect.js +1 -1
  5. package/dist/commands/models.js +8 -2
  6. package/dist/commands/sessions.js +156 -44
  7. package/dist/commands/sync.js +70 -14
  8. package/dist/lib/agents.d.ts +0 -4
  9. package/dist/lib/agents.js +54 -5
  10. package/dist/lib/browser/drivers/ssh.js +4 -35
  11. package/dist/lib/computer-rpc.d.ts +6 -1
  12. package/dist/lib/computer-rpc.js +86 -3
  13. package/dist/lib/exec.js +14 -0
  14. package/dist/lib/models.js +138 -5
  15. package/dist/lib/runner.js +7 -7
  16. package/dist/lib/session/active.d.ts +13 -0
  17. package/dist/lib/session/active.js +79 -18
  18. package/dist/lib/session/cloud.js +2 -0
  19. package/dist/lib/session/db.d.ts +11 -0
  20. package/dist/lib/session/db.js +62 -5
  21. package/dist/lib/session/discover.d.ts +5 -0
  22. package/dist/lib/session/discover.js +81 -0
  23. package/dist/lib/session/parse.d.ts +15 -0
  24. package/dist/lib/session/parse.js +22 -2
  25. package/dist/lib/session/remote.d.ts +1 -1
  26. package/dist/lib/session/remote.js +8 -3
  27. package/dist/lib/session/state.d.ts +82 -0
  28. package/dist/lib/session/state.js +221 -0
  29. package/dist/lib/session/tail.d.ts +18 -0
  30. package/dist/lib/session/tail.js +57 -0
  31. package/dist/lib/session/types.d.ts +9 -0
  32. package/dist/lib/session/width.d.ts +29 -0
  33. package/dist/lib/session/width.js +91 -0
  34. package/dist/lib/shims.d.ts +17 -1
  35. package/dist/lib/shims.js +130 -6
  36. package/dist/lib/ssh-tunnel.d.ts +127 -0
  37. package/dist/lib/ssh-tunnel.js +346 -0
  38. package/dist/lib/state.d.ts +2 -0
  39. package/dist/lib/state.js +17 -1
  40. package/dist/lib/teams/agents.d.ts +11 -1
  41. package/dist/lib/teams/agents.js +16 -2
  42. package/dist/lib/types.d.ts +1 -0
  43. package/dist/lib/versions.d.ts +19 -0
  44. package/dist/lib/versions.js +84 -24
  45. package/package.json +1 -1
@@ -20,6 +20,7 @@ import { walkForFiles } from '../fs-walk.js';
20
20
  import { getConfigSymlinkVersion } from '../shims.js';
21
21
  import { SESSION_AGENTS } from './types.js';
22
22
  import { extractSessionTopic } from './prompt.js';
23
+ import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from './state.js';
23
24
  import { costOfUsage } from '../pricing/index.js';
24
25
  import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
25
26
  const HOME = os.homedir();
@@ -427,6 +428,10 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
427
428
  costUsd: scan.costUsd,
428
429
  durationMs: scan.durationMs,
429
430
  isTeamOrigin,
431
+ prUrl: scan.prUrl,
432
+ prNumber: scan.prNumber,
433
+ worktreeSlug: scan.worktreeSlug,
434
+ ticketId: scan.ticketId,
430
435
  };
431
436
  }
432
437
  else {
@@ -445,6 +450,10 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
445
450
  durationMs: scan.durationMs,
446
451
  topic: scan.topic,
447
452
  isTeamOrigin,
453
+ prUrl: scan.prUrl,
454
+ prNumber: scan.prNumber,
455
+ worktreeSlug: scan.worktreeSlug,
456
+ ticketId: scan.ticketId,
448
457
  };
449
458
  }
450
459
  return { meta, content: scan.contentText || '' };
@@ -610,6 +619,10 @@ async function readCodexMeta(filePath, account, currentVersion) {
610
619
  costUsd: scan.costUsd,
611
620
  durationMs: scan.durationMs,
612
621
  account,
622
+ prUrl: scan.prUrl,
623
+ prNumber: scan.prNumber,
624
+ worktreeSlug: scan.worktreeSlug,
625
+ ticketId: scan.ticketId,
613
626
  };
614
627
  return { meta, content: scan.contentText || '' };
615
628
  }
@@ -1543,6 +1556,12 @@ export async function scanClaudeSession(filePath) {
1543
1556
  let lastTsMs;
1544
1557
  const seenAssistantIds = new Set();
1545
1558
  const userTexts = [];
1559
+ // Durable PR signal: set only when an actual `gh pr create` Bash *command*
1560
+ // runs (structural — the command field, not any prose mentioning it), then
1561
+ // capture the pull URL from a later tool_result's output.
1562
+ let sawPrCreate = false;
1563
+ let prUrl;
1564
+ let prNumber;
1546
1565
  try {
1547
1566
  for await (const line of rl) {
1548
1567
  if (!line.trim())
@@ -1559,6 +1578,31 @@ export async function scanClaudeSession(filePath) {
1559
1578
  if (!entrypoint && typeof parsed.entrypoint === 'string') {
1560
1579
  entrypoint = parsed.entrypoint;
1561
1580
  }
1581
+ // PR signal, structurally: a Bash tool_use whose command is `gh pr create`
1582
+ // marks intent; the pull URL is then read from a tool_result's output.
1583
+ if (!prUrl) {
1584
+ if (!sawPrCreate && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
1585
+ for (const b of parsed.message.content) {
1586
+ if (b?.type === 'tool_use' && typeof b?.input?.command === 'string' && isPrCreateCommand(b.input.command)) {
1587
+ sawPrCreate = true;
1588
+ }
1589
+ }
1590
+ }
1591
+ if (sawPrCreate && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
1592
+ for (const b of parsed.message.content) {
1593
+ if (b?.type !== 'tool_result')
1594
+ continue;
1595
+ const text = typeof b.content === 'string'
1596
+ ? b.content
1597
+ : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
1598
+ const pr = extractPrUrl(text);
1599
+ if (pr) {
1600
+ prUrl = pr.url;
1601
+ prNumber = pr.number;
1602
+ }
1603
+ }
1604
+ }
1605
+ }
1562
1606
  // Track duration across every timestamped event, not just the first.
1563
1607
  if (typeof parsed.timestamp === 'string') {
1564
1608
  const ms = new Date(parsed.timestamp).getTime();
@@ -1643,6 +1687,8 @@ export async function scanClaudeSession(filePath) {
1643
1687
  // Prefer an explicit session title (user `/rename` > Claude auto-title) over
1644
1688
  // the first-prompt topic.
1645
1689
  const resolvedTopic = customTitle || aiTitle || topic;
1690
+ const worktree = detectWorktree(cwd, gitBranch);
1691
+ const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
1646
1692
  return {
1647
1693
  timestamp,
1648
1694
  cwd,
@@ -1655,6 +1701,10 @@ export async function scanClaudeSession(filePath) {
1655
1701
  costUsd: sawCost ? costUsd : undefined,
1656
1702
  durationMs,
1657
1703
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1704
+ prUrl,
1705
+ prNumber,
1706
+ worktreeSlug: worktree?.slug,
1707
+ ticketId: ticket?.id,
1658
1708
  };
1659
1709
  }
1660
1710
  /** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
@@ -1674,6 +1724,9 @@ async function scanCodexSession(filePath) {
1674
1724
  let firstTsMs;
1675
1725
  let lastTsMs;
1676
1726
  const userTexts = [];
1727
+ let sawPrCreate = false;
1728
+ let prUrl;
1729
+ let prNumber;
1677
1730
  try {
1678
1731
  for await (const line of rl) {
1679
1732
  if (!line.trim())
@@ -1685,6 +1738,28 @@ async function scanCodexSession(filePath) {
1685
1738
  catch {
1686
1739
  continue;
1687
1740
  }
1741
+ // PR signal, structurally: a Codex `function_call` whose command is
1742
+ // `gh pr create`, then the pull URL from a `function_call_output`.
1743
+ if (!prUrl && parsed.type === 'response_item') {
1744
+ const p = parsed.payload || {};
1745
+ if (!sawPrCreate && p.type === 'function_call') {
1746
+ let cmd = '';
1747
+ try {
1748
+ const args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
1749
+ cmd = String(args.command || args.cmd || '');
1750
+ }
1751
+ catch { /* non-JSON args */ }
1752
+ if (isPrCreateCommand(cmd))
1753
+ sawPrCreate = true;
1754
+ }
1755
+ if (sawPrCreate && p.type === 'function_call_output') {
1756
+ const pr = extractPrUrl(String(p.output || ''));
1757
+ if (pr) {
1758
+ prUrl = pr.url;
1759
+ prNumber = pr.number;
1760
+ }
1761
+ }
1762
+ }
1688
1763
  // Track duration across every timestamped event.
1689
1764
  if (typeof parsed.timestamp === 'string') {
1690
1765
  const ms = new Date(parsed.timestamp).getTime();
@@ -1755,6 +1830,8 @@ async function scanCodexSession(filePath) {
1755
1830
  const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
1756
1831
  ? lastTsMs - firstTsMs
1757
1832
  : undefined;
1833
+ const worktree = detectWorktree(cwd, gitBranch);
1834
+ const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
1758
1835
  return {
1759
1836
  sessionId,
1760
1837
  timestamp,
@@ -1767,6 +1844,10 @@ async function scanCodexSession(filePath) {
1767
1844
  costUsd,
1768
1845
  durationMs,
1769
1846
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1847
+ prUrl,
1848
+ prNumber,
1849
+ worktreeSlug: worktree?.slug,
1850
+ ticketId: ticket?.id,
1770
1851
  };
1771
1852
  }
1772
1853
  /** Resolve the working directory for an OpenClaw agent from its workspace config. */
@@ -13,6 +13,8 @@ import type { SessionAgentId, SessionEvent } from './types.js';
13
13
  */
14
14
  export declare const SESSION_FILE_MAX_BYTES = 200000000;
15
15
  export declare function sanitizeForTerminal(s: string): string;
16
+ /** In-place sanitize every user-visible string field on a list of events. */
17
+ export declare function sanitizeEvents(events: SessionEvent[]): void;
16
18
  /**
17
19
  * Read a session file, refusing files above maxBytes. Bounded read protects
18
20
  * against multi-GB session blobs that would OOM the CLI or exceed V8's
@@ -31,8 +33,21 @@ export declare function detectAgent(filePath: string): SessionAgentId | null;
31
33
  export declare function summarizeToolUse(tool: string, args?: Record<string, any>): string;
32
34
  /** Parse a Claude JSONL session file into normalized events. */
33
35
  export declare function parseClaude(filePath: string): SessionEvent[];
36
+ /**
37
+ * Parse Claude JSONL *content* (already read into a string) into normalized
38
+ * events. Split from `parseClaude` so the tail reader can parse just the last
39
+ * chunk of a file without re-reading the whole thing. Malformed leading lines
40
+ * (a tail that starts mid-line) are skipped by the per-line try/catch below.
41
+ */
42
+ export declare function parseClaudeContent(content: string): SessionEvent[];
34
43
  /** Parse a Codex JSONL session file into normalized events. */
35
44
  export declare function parseCodex(filePath: string): SessionEvent[];
45
+ /**
46
+ * Parse Codex JSONL *content* (already read into a string) into normalized
47
+ * events. Split from `parseCodex` so the tail reader can parse just the last
48
+ * chunk without re-reading the whole file.
49
+ */
50
+ export declare function parseCodexContent(content: string): SessionEvent[];
36
51
  /** Parse a Gemini JSON session file into normalized events. */
37
52
  export declare function parseGemini(filePath: string): SessionEvent[];
38
53
  /**
@@ -41,6 +41,11 @@ function sanitizeArgsDeep(value) {
41
41
  }
42
42
  return value;
43
43
  }
44
+ /** In-place sanitize every user-visible string field on a list of events. */
45
+ export function sanitizeEvents(events) {
46
+ for (const e of events)
47
+ sanitizeEvent(e);
48
+ }
44
49
  /** In-place sanitize all user-visible string fields on an event. */
45
50
  function sanitizeEvent(e) {
46
51
  if (e.content)
@@ -214,7 +219,15 @@ function shortenPath(p) {
214
219
  // ---------------------------------------------------------------------------
215
220
  /** Parse a Claude JSONL session file into normalized events. */
216
221
  export function parseClaude(filePath) {
217
- const content = safeReadSessionFile(filePath);
222
+ return parseClaudeContent(safeReadSessionFile(filePath));
223
+ }
224
+ /**
225
+ * Parse Claude JSONL *content* (already read into a string) into normalized
226
+ * events. Split from `parseClaude` so the tail reader can parse just the last
227
+ * chunk of a file without re-reading the whole thing. Malformed leading lines
228
+ * (a tail that starts mid-line) are skipped by the per-line try/catch below.
229
+ */
230
+ export function parseClaudeContent(content) {
218
231
  const lines = content.split('\n').filter(l => l.trim());
219
232
  const events = [];
220
233
  // Map tool_use id -> {tool, args} for correlating with tool_result
@@ -403,7 +416,14 @@ export function parseClaude(filePath) {
403
416
  // ---------------------------------------------------------------------------
404
417
  /** Parse a Codex JSONL session file into normalized events. */
405
418
  export function parseCodex(filePath) {
406
- const content = safeReadSessionFile(filePath);
419
+ return parseCodexContent(safeReadSessionFile(filePath));
420
+ }
421
+ /**
422
+ * Parse Codex JSONL *content* (already read into a string) into normalized
423
+ * events. Split from `parseCodex` so the tail reader can parse just the last
424
+ * chunk without re-reading the whole file.
425
+ */
426
+ export function parseCodexContent(content) {
407
427
  const lines = content.split('\n').filter(l => l.trim());
408
428
  const events = [];
409
429
  // Track function_call id -> name for correlating with function_call_output
@@ -23,7 +23,7 @@ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>):
23
23
  * are quoted for the inner login shell, then the whole `agents …` invocation is
24
24
  * quoted again so it survives `bash -lc <...>`.
25
25
  */
26
- export declare function buildRemoteCommand(forwardedArgs: string[]): string;
26
+ export declare function buildRemoteCommand(forwardedArgs: string[], columns?: number): string;
27
27
  /** The four outcomes of one `ssh <host> agents sessions …` invocation. */
28
28
  export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
29
29
  /**
@@ -28,6 +28,7 @@ import { createHash } from 'crypto';
28
28
  import chalk from 'chalk';
29
29
  import { getCacheDir } from '../state.js';
30
30
  import { formatRelativeTime } from './relative-time.js';
31
+ import { terminalWidth } from './width.js';
31
32
  /**
32
33
  * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
33
34
  * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
@@ -87,9 +88,13 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
87
88
  * are quoted for the inner login shell, then the whole `agents …` invocation is
88
89
  * quoted again so it survives `bash -lc <...>`.
89
90
  */
90
- export function buildRemoteCommand(forwardedArgs) {
91
+ export function buildRemoteCommand(forwardedArgs, columns) {
91
92
  const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
92
- return `bash -lc ${shellQuote(inner)}`;
93
+ // Forward the caller's terminal width so the remote renders the table to the
94
+ // local screen (over SSH the remote's own COLUMNS is unset/wrong). `VAR=val
95
+ // cmd` scopes the env to that process — the remote's terminalWidth() reads it.
96
+ const withCols = columns && columns > 0 ? `COLUMNS=${columns} ${inner}` : inner;
97
+ return `bash -lc ${shellQuote(withCols)}`;
93
98
  }
94
99
  const SSH_OPTS = [
95
100
  '-o', 'BatchMode=yes',
@@ -175,7 +180,7 @@ export function runRemoteSessions(hosts, argv = process.argv) {
175
180
  for (const host of hosts)
176
181
  assertValidSshTarget(host); // fail fast on any bad target
177
182
  const forwarded = buildForwardedArgs(argv, new Set(hosts));
178
- const remoteCmd = buildRemoteCommand(forwarded);
183
+ const remoteCmd = buildRemoteCommand(forwarded, terminalWidth());
179
184
  const multi = hosts.length > 1;
180
185
  let failures = 0;
181
186
  for (const host of hosts) {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Session state inference.
3
+ *
4
+ * Turns a chronological slice of normalized `SessionEvent`s (typically the tail
5
+ * of a transcript) plus lightweight context (file mtime, cwd, branch, whether
6
+ * the owning process is alive) into a `SessionState`: is the agent working,
7
+ * waiting on the user, or idle — and did it open a PR, is it in a worktree, is
8
+ * it tied to a tracker ticket. Pure functions, no I/O, so the whole thing is
9
+ * unit-testable and shared by both the live `--active` path and the incremental
10
+ * scanner (which persists the durable signals to the index).
11
+ *
12
+ * Structural signals are preferred over prose heuristics: Claude's
13
+ * `ExitPlanMode` / `AskUserQuestion` tool calls are exact "waiting on you"
14
+ * markers. Codex has no such tools, so it falls back to last-role + question
15
+ * shape + mtime — same function, driven off the normalized events.
16
+ */
17
+ import type { SessionEvent } from './types.js';
18
+ export type SessionActivity = 'working' | 'waiting_input' | 'idle';
19
+ export type AwaitingReason = 'question' | 'plan_review' | 'permission';
20
+ export interface DetectedPr {
21
+ url: string;
22
+ number?: number;
23
+ }
24
+ export interface DetectedWorktree {
25
+ /** Absolute worktree path (the session cwd). */
26
+ path: string;
27
+ /** The `<slug>` under `.agents/worktrees/`. */
28
+ slug: string;
29
+ branch?: string;
30
+ }
31
+ export interface DetectedTicket {
32
+ /** Tracker key, e.g. `RUSH-1234`. */
33
+ id: string;
34
+ url?: string;
35
+ }
36
+ export interface SessionState {
37
+ activity: SessionActivity;
38
+ awaitingReason?: AwaitingReason;
39
+ lastRole?: 'user' | 'assistant';
40
+ lastEventKind?: SessionEvent['type'];
41
+ /** Single-line description of the latest turn (message text or tool action). */
42
+ preview?: string;
43
+ lastActivityMs?: number;
44
+ pr?: DetectedPr;
45
+ worktree?: DetectedWorktree;
46
+ ticket?: DetectedTicket;
47
+ }
48
+ export interface StateContext {
49
+ /** Session file mtime; drives running-vs-stale. */
50
+ mtimeMs?: number;
51
+ cwd?: string;
52
+ gitBranch?: string;
53
+ /** Whether the owning OS process is alive (from the active scanner). */
54
+ pidAlive?: boolean;
55
+ /** Override the running window (defaults to 2 min, matching active.ts). */
56
+ activeWindowMs?: number;
57
+ }
58
+ /** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */
59
+ export declare function detectWorktree(cwd?: string, branch?: string): DetectedWorktree | undefined;
60
+ /** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
61
+ export declare function detectTicket(text?: string, branch?: string): DetectedTicket | undefined;
62
+ /** Pull a PR URL + number out of tool-result output text. */
63
+ export declare function extractPrUrl(output?: string): DetectedPr | undefined;
64
+ /** True when a Bash/exec command string is a `gh pr create`. */
65
+ export declare function isPrCreateCommand(command?: string): boolean;
66
+ /**
67
+ * Infer live activity + a preview from a chronological event slice. `pr` /
68
+ * `ticket` / `worktree` are attached by `inferSessionState`; this focuses on the
69
+ * running-vs-waiting-vs-idle decision and the preview line.
70
+ */
71
+ export declare function inferActivity(events: SessionEvent[], ctx?: StateContext): SessionState;
72
+ /**
73
+ * Scan an event slice for the durable signals (PR opened, ticket) that aren't
74
+ * about the cwd. Correlates each `gh pr create` with the nearest following
75
+ * tool_result URL; keeps the last PR found.
76
+ */
77
+ export declare function detectDurableSignals(events: SessionEvent[]): {
78
+ pr?: DetectedPr;
79
+ ticket?: DetectedTicket;
80
+ };
81
+ /** Full inference: activity + preview + durable signals + worktree/ticket from ctx. */
82
+ export declare function inferSessionState(events: SessionEvent[], ctx?: StateContext): SessionState;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Session state inference.
3
+ *
4
+ * Turns a chronological slice of normalized `SessionEvent`s (typically the tail
5
+ * of a transcript) plus lightweight context (file mtime, cwd, branch, whether
6
+ * the owning process is alive) into a `SessionState`: is the agent working,
7
+ * waiting on the user, or idle — and did it open a PR, is it in a worktree, is
8
+ * it tied to a tracker ticket. Pure functions, no I/O, so the whole thing is
9
+ * unit-testable and shared by both the live `--active` path and the incremental
10
+ * scanner (which persists the durable signals to the index).
11
+ *
12
+ * Structural signals are preferred over prose heuristics: Claude's
13
+ * `ExitPlanMode` / `AskUserQuestion` tool calls are exact "waiting on you"
14
+ * markers. Codex has no such tools, so it falls back to last-role + question
15
+ * shape + mtime — same function, driven off the normalized events.
16
+ */
17
+ import { summarizeToolUse } from './parse.js';
18
+ /** A healthy live session writes several times a minute; 2 min ⇒ "recently active". */
19
+ const ACTIVE_WINDOW_MS = 2 * 60_000;
20
+ /** Claude tool names that structurally mean "the agent handed control back to you". */
21
+ const PLAN_TOOL = 'ExitPlanMode';
22
+ const ASK_TOOL = 'AskUserQuestion';
23
+ /** Trailing '?' or a leading interrogative — a question aimed at the user. */
24
+ const QUESTION_TRAILING = /\?["'”)\]]?\s*$/;
25
+ const QUESTION_PHRASE = /\b(shall i|should i|do you want|would you like|which (?:one|option|approach|of)|can you (?:confirm|clarify)|please (?:confirm|clarify|advise)|let me know|are you (?:ok|okay|sure)|proceed\?)\b/i;
26
+ /**
27
+ * Linear/Jira-style ref, e.g. RUSH-1234. Team key is letters-only (2–6) so a
28
+ * regex snippet like `[A-Z0-9]-\d` in a code discussion can't masquerade as a
29
+ * ticket. Uppercase-only so we don't match `utf-8`.
30
+ */
31
+ const TICKET_RE = /\b([A-Z]{2,6}-\d{1,6})\b/;
32
+ /** Lowercase branch form (Linear branch names): muqsit/rush-1234-fix. */
33
+ const TICKET_BRANCH_RE = /(?:^|[/_-])([a-z]{2,6})-(\d{2,6})(?=[/_-]|$)/;
34
+ /** Keys that look like tickets but aren't — avoid false positives from branches. */
35
+ const TICKET_DENYLIST = new Set(['UTF', 'SHA', 'ISO', 'RFC', 'IPV', 'X86', 'ARM', 'MP', 'H']);
36
+ const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
37
+ const WORKTREE_RE = /\/\.agents\/worktrees\/([^/]+)/;
38
+ /** gh invocations that create/open a PR. */
39
+ const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
40
+ /** Collapse to a single trimmed line for a one-row preview cell. */
41
+ function oneLine(s) {
42
+ return s.replace(/\s+/g, ' ').trim();
43
+ }
44
+ /** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */
45
+ export function detectWorktree(cwd, branch) {
46
+ if (!cwd)
47
+ return undefined;
48
+ const m = cwd.match(WORKTREE_RE);
49
+ if (!m)
50
+ return undefined;
51
+ return { path: cwd, slug: m[1], branch: branch || undefined };
52
+ }
53
+ /** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
54
+ export function detectTicket(text, branch) {
55
+ if (text) {
56
+ const m = text.match(TICKET_RE);
57
+ if (m && !TICKET_DENYLIST.has(m[1].split('-')[0]))
58
+ return { id: m[1] };
59
+ }
60
+ if (branch) {
61
+ const m = branch.match(TICKET_BRANCH_RE);
62
+ if (m) {
63
+ const key = m[1].toUpperCase();
64
+ if (!TICKET_DENYLIST.has(key))
65
+ return { id: `${key}-${m[2]}` };
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+ /** Pull a PR URL + number out of tool-result output text. */
71
+ export function extractPrUrl(output) {
72
+ if (!output)
73
+ return undefined;
74
+ const m = output.match(PR_URL_RE);
75
+ if (!m)
76
+ return undefined;
77
+ return { url: m[0], number: Number.parseInt(m[1], 10) };
78
+ }
79
+ /** True when a Bash/exec command string is a `gh pr create`. */
80
+ export function isPrCreateCommand(command) {
81
+ return !!command && GH_PR_CREATE_RE.test(command);
82
+ }
83
+ /** Does an assistant message read as a question directed at the user? */
84
+ function looksLikeQuestion(text) {
85
+ const t = text.trim();
86
+ if (!t)
87
+ return false;
88
+ // Only weigh the final line — a long answer that ends with a question is a question.
89
+ const lastLine = t.split('\n').filter(Boolean).pop() ?? t;
90
+ return QUESTION_TRAILING.test(lastLine) || QUESTION_PHRASE.test(lastLine);
91
+ }
92
+ /** Human-readable one-liner for the latest event (message text or tool action). */
93
+ function describeEvent(e) {
94
+ if (e.type === 'message' && e.content)
95
+ return oneLine(e.content);
96
+ if (e.type === 'tool_use' && e.tool)
97
+ return oneLine(summarizeToolUse(e.tool, e.args));
98
+ if (e.type === 'thinking')
99
+ return 'thinking…';
100
+ if (e.type === 'tool_result')
101
+ return e.tool ? `↳ ${e.tool}` : undefined;
102
+ if (e.type === 'error')
103
+ return oneLine(e.content || 'error');
104
+ return e.content ? oneLine(e.content) : undefined;
105
+ }
106
+ /** Last event of a given type, scanning from the end. */
107
+ function lastOf(events, pred) {
108
+ for (let i = events.length - 1; i >= 0; i--)
109
+ if (pred(events[i]))
110
+ return events[i];
111
+ return undefined;
112
+ }
113
+ /**
114
+ * Infer live activity + a preview from a chronological event slice. `pr` /
115
+ * `ticket` / `worktree` are attached by `inferSessionState`; this focuses on the
116
+ * running-vs-waiting-vs-idle decision and the preview line.
117
+ */
118
+ export function inferActivity(events, ctx = {}) {
119
+ const windowMs = ctx.activeWindowMs ?? ACTIVE_WINDOW_MS;
120
+ const fresh = ctx.mtimeMs != null && Date.now() - ctx.mtimeMs < windowMs;
121
+ // A non-live process (pidAlive === false) can never be "working"; the strongest
122
+ // it gets is "waiting on you" (a dangling question) or "idle".
123
+ const canWork = ctx.pidAlive !== false && (ctx.pidAlive === true || fresh);
124
+ const meaningful = events.filter(e => e.type === 'message' || e.type === 'tool_use' || e.type === 'tool_result' || e.type === 'thinking' || e.type === 'error');
125
+ const last = meaningful[meaningful.length - 1];
126
+ const lastMsg = lastOf(meaningful, e => e.type === 'message');
127
+ const lastToolUse = lastOf(meaningful, e => e.type === 'tool_use');
128
+ // The most informative recent line: a message or tool call as-is, but for a
129
+ // trailing tool_result/thinking show the tool *call* that produced it (its
130
+ // command) rather than a bare "↳ Bash".
131
+ const previewSource = !last
132
+ ? undefined
133
+ : last.type === 'message' || last.type === 'tool_use'
134
+ ? last
135
+ : (lastToolUse ?? last);
136
+ const base = {
137
+ activity: 'idle',
138
+ lastRole: lastMsg?.role,
139
+ lastEventKind: last?.type,
140
+ lastActivityMs: ctx.mtimeMs,
141
+ preview: previewSource ? describeEvent(previewSource) : undefined,
142
+ };
143
+ if (!last)
144
+ return base;
145
+ // Structural "waiting on you" — Claude handed control back via a plan/question
146
+ // tool and nothing has come after it.
147
+ const lastPlanOrAsk = lastOf(meaningful, e => e.type === 'tool_use' && (e.tool === PLAN_TOOL || e.tool === ASK_TOOL));
148
+ if (lastPlanOrAsk && meaningful.indexOf(lastPlanOrAsk) === meaningful.length - 1) {
149
+ return {
150
+ ...base,
151
+ activity: 'waiting_input',
152
+ awaitingReason: lastPlanOrAsk.tool === PLAN_TOOL ? 'plan_review' : 'question',
153
+ preview: lastPlanOrAsk.tool === PLAN_TOOL ? 'Plan ready — awaiting your review' : 'Asked you a question',
154
+ };
155
+ }
156
+ // Pending tool call (tool_use with no following tool_result): mid-turn.
157
+ if (last.type === 'tool_use') {
158
+ if (canWork && fresh)
159
+ return { ...base, activity: 'working' };
160
+ // Alive but the file hasn't moved — likely blocked on a permission prompt.
161
+ if (ctx.pidAlive)
162
+ return { ...base, activity: 'waiting_input', awaitingReason: 'permission' };
163
+ return { ...base, activity: 'idle' };
164
+ }
165
+ // Thinking or a tool result just landed → agent is mid-turn if recently active.
166
+ if (last.type === 'thinking' || last.type === 'tool_result' || last.type === 'error') {
167
+ return { ...base, activity: canWork && fresh ? 'working' : 'idle' };
168
+ }
169
+ // Last event is a message.
170
+ if (last.type === 'message') {
171
+ if (last.role === 'user') {
172
+ // User spoke last; the agent owes a reply → working if it's alive/fresh.
173
+ return { ...base, activity: canWork ? 'working' : 'idle' };
174
+ }
175
+ // Assistant spoke last and stopped. A trailing question → waiting; else idle.
176
+ if (looksLikeQuestion(last.content ?? '')) {
177
+ return { ...base, activity: 'waiting_input', awaitingReason: 'question' };
178
+ }
179
+ return { ...base, activity: 'idle' };
180
+ }
181
+ return base;
182
+ }
183
+ /**
184
+ * Scan an event slice for the durable signals (PR opened, ticket) that aren't
185
+ * about the cwd. Correlates each `gh pr create` with the nearest following
186
+ * tool_result URL; keeps the last PR found.
187
+ */
188
+ export function detectDurableSignals(events) {
189
+ let pr;
190
+ let sawPrCreate = false;
191
+ let ticket;
192
+ for (const e of events) {
193
+ // Structural PR signal: a real `gh pr create` tool call, then the pull URL
194
+ // from a following tool_result — never a bare URL mentioned in prose.
195
+ if (e.type === 'tool_use' && isPrCreateCommand(e.command))
196
+ sawPrCreate = true;
197
+ if (sawPrCreate && e.type === 'tool_result') {
198
+ const found = extractPrUrl(e.output);
199
+ if (found) {
200
+ pr = found;
201
+ sawPrCreate = false;
202
+ }
203
+ }
204
+ if (!ticket && e.type === 'message' && e.role === 'user') {
205
+ ticket = detectTicket(e.content);
206
+ }
207
+ }
208
+ return { pr, ticket };
209
+ }
210
+ /** Full inference: activity + preview + durable signals + worktree/ticket from ctx. */
211
+ export function inferSessionState(events, ctx = {}) {
212
+ const state = inferActivity(events, ctx);
213
+ const { pr, ticket } = detectDurableSignals(events);
214
+ const worktree = detectWorktree(ctx.cwd, ctx.gitBranch);
215
+ return {
216
+ ...state,
217
+ pr: pr ?? state.pr,
218
+ worktree: worktree ?? state.worktree,
219
+ ticket: ticket ?? detectTicket(undefined, ctx.gitBranch) ?? state.ticket,
220
+ };
221
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Fast tail read of a session transcript.
3
+ *
4
+ * The live `--active` view needs the *last* few events of a possibly-huge JSONL
5
+ * to infer state — parsing the whole file per row would make the view crawl. We
6
+ * read only the final chunk from an fd (mirroring the bounded head-read in
7
+ * active.ts's `quickExtractTopic`), drop a partial leading line, and hand the
8
+ * chunk to the existing content parsers so there's zero duplicated parse logic.
9
+ */
10
+ import type { SessionAgentId, SessionEvent } from './types.js';
11
+ /**
12
+ * Read the last `maxBytes` of a JSONL transcript and return its last
13
+ * `maxEvents` normalized events. A tail that begins mid-line yields one
14
+ * malformed leading line, which the per-line JSON try/catch in the content
15
+ * parsers skips. Only Claude and Codex are supported (the prioritized harnesses
16
+ * for live state); other agents return `[]`.
17
+ */
18
+ export declare function readSessionTail(filePath: string, agent: SessionAgentId, maxBytes?: number, maxEvents?: number): SessionEvent[];
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Fast tail read of a session transcript.
3
+ *
4
+ * The live `--active` view needs the *last* few events of a possibly-huge JSONL
5
+ * to infer state — parsing the whole file per row would make the view crawl. We
6
+ * read only the final chunk from an fd (mirroring the bounded head-read in
7
+ * active.ts's `quickExtractTopic`), drop a partial leading line, and hand the
8
+ * chunk to the existing content parsers so there's zero duplicated parse logic.
9
+ */
10
+ import * as fs from 'fs';
11
+ import { parseClaudeContent, parseCodexContent, sanitizeEvents } from './parse.js';
12
+ const DEFAULT_MAX_BYTES = 128 * 1024;
13
+ const DEFAULT_MAX_EVENTS = 60;
14
+ /**
15
+ * Read the last `maxBytes` of a JSONL transcript and return its last
16
+ * `maxEvents` normalized events. A tail that begins mid-line yields one
17
+ * malformed leading line, which the per-line JSON try/catch in the content
18
+ * parsers skips. Only Claude and Codex are supported (the prioritized harnesses
19
+ * for live state); other agents return `[]`.
20
+ */
21
+ export function readSessionTail(filePath, agent, maxBytes = DEFAULT_MAX_BYTES, maxEvents = DEFAULT_MAX_EVENTS) {
22
+ if (agent !== 'claude' && agent !== 'codex')
23
+ return [];
24
+ let fd;
25
+ try {
26
+ fd = fs.openSync(filePath, 'r');
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ try {
32
+ const size = fs.fstatSync(fd).size;
33
+ if (size === 0)
34
+ return [];
35
+ const start = Math.max(0, size - maxBytes);
36
+ const len = size - start;
37
+ const buf = Buffer.alloc(len);
38
+ fs.readSync(fd, buf, 0, len, start);
39
+ let content = buf.toString('utf8');
40
+ // If we started mid-file, the first line is almost certainly partial — drop it.
41
+ if (start > 0) {
42
+ const nl = content.indexOf('\n');
43
+ content = nl >= 0 ? content.slice(nl + 1) : '';
44
+ }
45
+ if (!content.trim())
46
+ return [];
47
+ const events = agent === 'codex' ? parseCodexContent(content) : parseClaudeContent(content);
48
+ sanitizeEvents(events);
49
+ return events.length > maxEvents ? events.slice(-maxEvents) : events;
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ finally {
55
+ fs.closeSync(fd);
56
+ }
57
+ }