@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.
- package/dist/commands/computer-actions.js +6 -2
- package/dist/commands/computer.d.ts +12 -0
- package/dist/commands/computer.js +88 -13
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/sessions.js +156 -44
- package/dist/commands/sync.js +70 -14
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +54 -5
- package/dist/lib/browser/drivers/ssh.js +4 -35
- package/dist/lib/computer-rpc.d.ts +6 -1
- package/dist/lib/computer-rpc.js +86 -3
- package/dist/lib/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- package/dist/lib/session/active.d.ts +13 -0
- package/dist/lib/session/active.js +79 -18
- package/dist/lib/session/cloud.js +2 -0
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +62 -5
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +81 -0
- package/dist/lib/session/parse.d.ts +15 -0
- package/dist/lib/session/parse.js +22 -2
- package/dist/lib/session/remote.d.ts +1 -1
- package/dist/lib/session/remote.js +8 -3
- package/dist/lib/session/state.d.ts +82 -0
- package/dist/lib/session/state.js +221 -0
- package/dist/lib/session/tail.d.ts +18 -0
- package/dist/lib/session/tail.js +57 -0
- package/dist/lib/session/types.d.ts +9 -0
- package/dist/lib/session/width.d.ts +29 -0
- package/dist/lib/session/width.js +91 -0
- package/dist/lib/shims.d.ts +17 -1
- package/dist/lib/shims.js +130 -6
- package/dist/lib/ssh-tunnel.d.ts +127 -0
- package/dist/lib/ssh-tunnel.js +346 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +17 -1
- package/dist/lib/teams/agents.d.ts +11 -1
- package/dist/lib/teams/agents.js +16 -2
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/versions.d.ts +19 -0
- package/dist/lib/versions.js +84 -24
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|