@phnx-labs/agents-cli 1.20.29 → 1.20.31
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-picker.js +35 -10
- package/dist/commands/sessions.js +164 -44
- package/dist/commands/setup.js +8 -0
- package/dist/commands/ssh.js +123 -15
- package/dist/commands/sync.js +70 -14
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +122 -22
- 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/devices/registry.d.ts +11 -0
- package/dist/lib/devices/registry.js +53 -1
- package/dist/lib/devices/sync.d.ts +42 -0
- package/dist/lib/devices/sync.js +85 -0
- 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 +15 -0
- package/dist/lib/session/active.js +108 -19
- 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/digest.d.ts +50 -0
- package/dist/lib/session/digest.js +170 -0
- 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/render.d.ts +2 -0
- package/dist/lib/session/render.js +83 -10
- 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 +4 -0
- package/dist/lib/state.js +19 -1
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +10 -0
- 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) {
|
|
@@ -49,6 +49,8 @@ export interface SessionStats {
|
|
|
49
49
|
userTurns: number;
|
|
50
50
|
assistantTurns: number;
|
|
51
51
|
toolCount: number;
|
|
52
|
+
/** Per-tool call counts (histogram), highest first when rendered. */
|
|
53
|
+
toolCounts: Record<string, number>;
|
|
52
54
|
errorCount: number;
|
|
53
55
|
outputTokens: number;
|
|
54
56
|
cacheReadTokens: number;
|
|
@@ -11,6 +11,7 @@ import { summarizeToolUse } from './parse.js';
|
|
|
11
11
|
import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
|
|
12
12
|
import { renderMarkdown } from '../markdown.js';
|
|
13
13
|
import { redactSecrets } from '../redact.js';
|
|
14
|
+
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
|
|
14
15
|
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
15
16
|
/**
|
|
16
17
|
* Return absPath relative to cwd; fall back to ~/… then absolute.
|
|
@@ -153,6 +154,7 @@ export function collapseRetries(commands) {
|
|
|
153
154
|
/** Compute aggregate statistics (turns, tools, tokens, duration) from session events. */
|
|
154
155
|
export function computeSummaryStats(events) {
|
|
155
156
|
const modelSet = new Set();
|
|
157
|
+
const toolCounts = {};
|
|
156
158
|
let userTurns = 0;
|
|
157
159
|
let assistantTurns = 0;
|
|
158
160
|
let toolCount = 0;
|
|
@@ -177,6 +179,8 @@ export function computeSummaryStats(events) {
|
|
|
177
179
|
}
|
|
178
180
|
else if (e.type === 'tool_use' && !e._local) {
|
|
179
181
|
toolCount++;
|
|
182
|
+
if (e.tool)
|
|
183
|
+
toolCounts[e.tool] = (toolCounts[e.tool] ?? 0) + 1;
|
|
180
184
|
}
|
|
181
185
|
else if (e.type === 'error') {
|
|
182
186
|
errorCount++;
|
|
@@ -193,6 +197,7 @@ export function computeSummaryStats(events) {
|
|
|
193
197
|
userTurns,
|
|
194
198
|
assistantTurns,
|
|
195
199
|
toolCount,
|
|
200
|
+
toolCounts,
|
|
196
201
|
errorCount,
|
|
197
202
|
outputTokens,
|
|
198
203
|
cacheReadTokens,
|
|
@@ -426,6 +431,76 @@ function renderActivityLine(item) {
|
|
|
426
431
|
return chalk.green('Msg ') + ' ' + chalk.gray('"' + trim(item.label) + '"');
|
|
427
432
|
}
|
|
428
433
|
}
|
|
434
|
+
// ── Catch-up digest sections ──────────────────────────────────────────────────
|
|
435
|
+
const OP_GLYPH = {
|
|
436
|
+
created: (s) => chalk.green(s),
|
|
437
|
+
modified: (s) => chalk.yellow(s),
|
|
438
|
+
deleted: (s) => chalk.red(s),
|
|
439
|
+
};
|
|
440
|
+
const OP_MARK = { created: '+', modified: '~', deleted: '−' };
|
|
441
|
+
/**
|
|
442
|
+
* Render the Changes section: files grouped by directory, each tagged with its
|
|
443
|
+
* create/modify/delete lifecycle, plus a `+N ~N −N` summary. Replaces the old
|
|
444
|
+
* flat "Modified" list. Returns true if anything was rendered.
|
|
445
|
+
*/
|
|
446
|
+
function renderChangesSection(lines, events, cwd) {
|
|
447
|
+
// In-project changes only; edits outside cwd (e.g. /tmp) keep their own
|
|
448
|
+
// "External edits" section so they don't clutter the project's changeset.
|
|
449
|
+
const inCwd = (p) => !cwd || !p.startsWith('/') || p.startsWith(cwd + '/');
|
|
450
|
+
const changes = classifyFileChanges(events).filter(ch => inCwd(ch.path));
|
|
451
|
+
if (changes.length === 0)
|
|
452
|
+
return false;
|
|
453
|
+
const c = changeCounts(changes);
|
|
454
|
+
const opByRel = new Map();
|
|
455
|
+
for (const ch of changes)
|
|
456
|
+
opByRel.set(relativeToCwd(ch.path, cwd), ch.op);
|
|
457
|
+
const summary = [
|
|
458
|
+
c.created ? chalk.green(`+${c.created}`) : '',
|
|
459
|
+
c.modified ? chalk.yellow(`~${c.modified}`) : '',
|
|
460
|
+
c.deleted ? chalk.red(`−${c.deleted}`) : '',
|
|
461
|
+
].filter(Boolean).join(' ');
|
|
462
|
+
lines.push(chalk.bold('Changes') + chalk.gray(` (${changes.length}) `) + summary);
|
|
463
|
+
const groups = groupByParentDir(changes.map(ch => ch.path), cwd);
|
|
464
|
+
const single = groups.size === 1;
|
|
465
|
+
for (const [dir, files] of groups) {
|
|
466
|
+
// Single dir: show the full relative path per file (dir/base). Multiple
|
|
467
|
+
// dirs: a dir header, then bare filenames under it.
|
|
468
|
+
if (!single)
|
|
469
|
+
lines.push(' ' + chalk.dim(dir + '/'));
|
|
470
|
+
for (const f of files.sort()) {
|
|
471
|
+
const rel = dir === '.' ? f : `${dir}/${f}`;
|
|
472
|
+
const op = opByRel.get(rel) ?? 'modified';
|
|
473
|
+
const shown = single ? rel : f;
|
|
474
|
+
const name = op === 'deleted' ? chalk.strikethrough(chalk.gray(shown)) : shown;
|
|
475
|
+
lines.push((single ? ' ' : ' ') + OP_GLYPH[op](OP_MARK[op]) + ' ' + name);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
lines.push('');
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
/** Render the tool histogram: `Edit 61 · Bash 48 · Read 35 …`. */
|
|
482
|
+
function renderToolsSection(lines, stats) {
|
|
483
|
+
const hist = toolHistogram(stats.toolCounts, 8);
|
|
484
|
+
if (hist.length === 0)
|
|
485
|
+
return;
|
|
486
|
+
const parts = hist.map(h => `${chalk.white(h.tool)} ${chalk.gray(String(h.count))}`);
|
|
487
|
+
lines.push(chalk.bold('Tools') + ' ' + parts.join(chalk.gray(' · ')));
|
|
488
|
+
lines.push('');
|
|
489
|
+
}
|
|
490
|
+
/** Render the last test/build verdict, e.g. `Tests tests: 294 pass · 4 fail`. */
|
|
491
|
+
function renderTestsLine(lines, events) {
|
|
492
|
+
const r = detectTestResult(events);
|
|
493
|
+
if (!r || !r.ok)
|
|
494
|
+
return;
|
|
495
|
+
const bits = [];
|
|
496
|
+
if (r.passed !== undefined)
|
|
497
|
+
bits.push(chalk.green(`${r.passed} pass`));
|
|
498
|
+
if (r.failed !== undefined)
|
|
499
|
+
bits.push(r.failed > 0 ? chalk.red(`${r.failed} fail`) : chalk.gray('0 fail'));
|
|
500
|
+
const verdict = r.failed && r.failed > 0 ? chalk.red('✗') : chalk.green('✓');
|
|
501
|
+
lines.push(chalk.bold('Tests') + ` ${verdict} ${chalk.cyan(r.runner)} ${bits.join(chalk.gray(' · '))}`);
|
|
502
|
+
lines.push('');
|
|
503
|
+
}
|
|
429
504
|
// ── Main summary renderer ─────────────────────────────────────────────────────
|
|
430
505
|
/**
|
|
431
506
|
* Render session as an activity summary.
|
|
@@ -560,7 +635,6 @@ export function renderSummary(events, cwd) {
|
|
|
560
635
|
}
|
|
561
636
|
return m;
|
|
562
637
|
};
|
|
563
|
-
const modifiedAbsMap = buildAbsMap(filesModifiedAbs);
|
|
564
638
|
const readAbsMap = buildAbsMap(filesReadAbs);
|
|
565
639
|
// ── Render sections ───────────────────────────────────────────────────────
|
|
566
640
|
const lines = [''];
|
|
@@ -640,15 +714,14 @@ export function renderSummary(events, cwd) {
|
|
|
640
714
|
chalk.gray(`: ${errors.length} failure${errors.length !== 1 ? 's' : ''} — first: ${firstDesc}`));
|
|
641
715
|
lines.push('');
|
|
642
716
|
}
|
|
643
|
-
// 6.
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
//
|
|
651
|
-
// Filter out plan files (already shown in Plan section)
|
|
717
|
+
// 6. Changes — files grouped by directory with create/modify/delete lifecycle
|
|
718
|
+
// (replaces the old flat "Modified" + "External edits" lists).
|
|
719
|
+
renderChangesSection(lines, events, cwd);
|
|
720
|
+
// 6b. Catch-up signals: last test/build verdict, then the tool histogram.
|
|
721
|
+
renderTestsLine(lines, events);
|
|
722
|
+
renderToolsSection(lines, computeSummaryStats(events));
|
|
723
|
+
// 6c. External edits (files edited outside the project root — typically /tmp).
|
|
724
|
+
// Filter out plan files (already shown in Plan section).
|
|
652
725
|
const externalNonPlan = [...filesModifiedExternal].filter(p => !(p.includes('.claude/plans/') && p.endsWith('.md')));
|
|
653
726
|
if (externalNonPlan.length > 0) {
|
|
654
727
|
const externalList = externalNonPlan.sort();
|
|
@@ -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;
|