@phnx-labs/agents-cli 1.20.90 → 1.20.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/feed.js +77 -4
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/routines.js +30 -24
- package/dist/commands/secrets.d.ts +43 -4
- package/dist/commands/secrets.js +217 -32
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-picker.js +70 -1
- package/dist/index.js +18 -3
- package/dist/lib/activity.d.ts +11 -1
- package/dist/lib/activity.js +1 -0
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/digest.d.ts +7 -0
- package/dist/lib/session/digest.js +29 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/relative-time.d.ts +14 -0
- package/dist/lib/session/relative-time.js +36 -0
- package/dist/lib/session/render.d.ts +7 -0
- package/dist/lib/session/render.js +87 -17
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +9 -0
- package/dist/lib/state.js +11 -0
- package/package.json +3 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session highlight extractors.
|
|
3
|
+
*
|
|
4
|
+
* Pure derivations over a session's `SessionEvent[]` that power the "what did
|
|
5
|
+
* this session use and produce" sections of both renders of a session — the
|
|
6
|
+
* picker quick preview (`sessions-picker.ts`) and the full summary
|
|
7
|
+
* (`render.ts`). One module, two consumers, so the panes never drift.
|
|
8
|
+
*
|
|
9
|
+
* Skills/hooks/links are no-I/O. `extractRepos` touches the filesystem (a
|
|
10
|
+
* bounded `.git` walk over a handful of candidate dirs) and is the only
|
|
11
|
+
* non-pure function here.
|
|
12
|
+
*/
|
|
13
|
+
import type { SessionEvent } from './types.js';
|
|
14
|
+
import { type FileChange } from './digest.js';
|
|
15
|
+
export interface SkillUse {
|
|
16
|
+
name: string;
|
|
17
|
+
count: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Skills invoked during the session, from `Skill` tool calls. Claude and Kimi
|
|
21
|
+
* both name the tool `Skill` and carry the skill id in `args.skill` (plugin
|
|
22
|
+
* skills surface here too — a plugin-provided skill is invoked through the
|
|
23
|
+
* same tool). Sorted by count desc, then name.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractSkills(events: SessionEvent[]): SkillUse[];
|
|
26
|
+
export interface HookUse {
|
|
27
|
+
/** Hook name as configured, e.g. `SessionStart:startup`. */
|
|
28
|
+
name: string;
|
|
29
|
+
/** Lifecycle event, e.g. `SessionStart`. */
|
|
30
|
+
event?: string;
|
|
31
|
+
/** How many times it fired. */
|
|
32
|
+
count: number;
|
|
33
|
+
/** How many firings failed (non-`hook_success` records). */
|
|
34
|
+
failed: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Hooks that fired during the session, folded from `hook` events (parsed from
|
|
38
|
+
* Claude's `hook_success`/`hook_error`/… attachment records; other harnesses
|
|
39
|
+
* don't record firings in their transcripts, so they yield an empty list).
|
|
40
|
+
* Sorted by count desc, then name.
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractHooks(events: SessionEvent[]): HookUse[];
|
|
43
|
+
export type LinkKind = 'linear' | 'jira' | 'github' | 'gitlab' | 'other';
|
|
44
|
+
export interface SessionLink {
|
|
45
|
+
kind: LinkKind;
|
|
46
|
+
url: string;
|
|
47
|
+
/** Short display label: `RUSH-2076`, `PR#1755`, `owner/repo#123`, host. */
|
|
48
|
+
label: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Links mentioned in user/assistant messages, classified (Linear/Jira/GitHub/
|
|
52
|
+
* GitLab), deduped by URL AND by label (a session that quotes the same PR in
|
|
53
|
+
* five messages shows it once), first-seen order. Capped so a link-heavy
|
|
54
|
+
* session can't flood the pane.
|
|
55
|
+
*/
|
|
56
|
+
export declare function extractLinks(events: SessionEvent[]): SessionLink[];
|
|
57
|
+
export type ArtifactBucket = 'artifacts' | 'plans' | 'reports' | 'docs';
|
|
58
|
+
export interface ProducedArtifact {
|
|
59
|
+
/** Absolute (or session-relative) path of the created file. */
|
|
60
|
+
path: string;
|
|
61
|
+
basename: string;
|
|
62
|
+
bucket: ArtifactBucket;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Documents/files the session CREATED, from the already-classified changes
|
|
66
|
+
* (callers classify once — this never re-derives). Keeps the ones a human
|
|
67
|
+
* browses later: anything under `.agents/artifacts|plans|reports/`, plus other
|
|
68
|
+
* `*.md`/`*.html` creations. Source/config churn (the bulk of `+N`) stays in
|
|
69
|
+
* the Changes line.
|
|
70
|
+
*/
|
|
71
|
+
export declare function extractArtifacts(changes: FileChange[]): ProducedArtifact[];
|
|
72
|
+
/**
|
|
73
|
+
* Repos the session worked in, from the directories its file paths live under
|
|
74
|
+
* (a bounded `.git` walk-up; a `.git` FILE counts too — that's the worktree
|
|
75
|
+
* layout). Names are repo dir basenames, first-seen order, capped.
|
|
76
|
+
*
|
|
77
|
+
* Relative paths resolve against the SESSION's cwd only — when it is unknown
|
|
78
|
+
* (e.g. kimi rows carry no cwd today) they are skipped: resolving them against
|
|
79
|
+
* the viewer's process cwd attributes the session to whatever repo the CLI
|
|
80
|
+
* happens to run in, which is a wrong answer, not a degraded one.
|
|
81
|
+
*/
|
|
82
|
+
export declare function extractRepos(events: SessionEvent[], cwd?: string): string[];
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session highlight extractors.
|
|
3
|
+
*
|
|
4
|
+
* Pure derivations over a session's `SessionEvent[]` that power the "what did
|
|
5
|
+
* this session use and produce" sections of both renders of a session — the
|
|
6
|
+
* picker quick preview (`sessions-picker.ts`) and the full summary
|
|
7
|
+
* (`render.ts`). One module, two consumers, so the panes never drift.
|
|
8
|
+
*
|
|
9
|
+
* Skills/hooks/links are no-I/O. `extractRepos` touches the filesystem (a
|
|
10
|
+
* bounded `.git` walk over a handful of candidate dirs) and is the only
|
|
11
|
+
* non-pure function here.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { isNoisePath } from './digest.js';
|
|
17
|
+
/**
|
|
18
|
+
* Skills invoked during the session, from `Skill` tool calls. Claude and Kimi
|
|
19
|
+
* both name the tool `Skill` and carry the skill id in `args.skill` (plugin
|
|
20
|
+
* skills surface here too — a plugin-provided skill is invoked through the
|
|
21
|
+
* same tool). Sorted by count desc, then name.
|
|
22
|
+
*/
|
|
23
|
+
export function extractSkills(events) {
|
|
24
|
+
const counts = new Map();
|
|
25
|
+
for (const e of events) {
|
|
26
|
+
if (e.type !== 'tool_use' || e._local)
|
|
27
|
+
continue;
|
|
28
|
+
if (e.tool !== 'Skill')
|
|
29
|
+
continue;
|
|
30
|
+
const name = e.args?.skill ?? e.args?.name;
|
|
31
|
+
if (typeof name !== 'string' || !name.trim())
|
|
32
|
+
continue;
|
|
33
|
+
const key = name.trim();
|
|
34
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
35
|
+
}
|
|
36
|
+
return [...counts.entries()]
|
|
37
|
+
.map(([name, count]) => ({ name, count }))
|
|
38
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Hooks that fired during the session, folded from `hook` events (parsed from
|
|
42
|
+
* Claude's `hook_success`/`hook_error`/… attachment records; other harnesses
|
|
43
|
+
* don't record firings in their transcripts, so they yield an empty list).
|
|
44
|
+
* Sorted by count desc, then name.
|
|
45
|
+
*/
|
|
46
|
+
export function extractHooks(events) {
|
|
47
|
+
const byName = new Map();
|
|
48
|
+
for (const e of events) {
|
|
49
|
+
if (e.type !== 'hook')
|
|
50
|
+
continue;
|
|
51
|
+
const name = e.hookName?.trim() || e.hookEvent?.trim() || 'hook';
|
|
52
|
+
const existing = byName.get(name);
|
|
53
|
+
if (existing) {
|
|
54
|
+
existing.count++;
|
|
55
|
+
if (e.success === false)
|
|
56
|
+
existing.failed++;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
byName.set(name, {
|
|
60
|
+
name,
|
|
61
|
+
event: e.hookEvent,
|
|
62
|
+
count: 1,
|
|
63
|
+
failed: e.success === false ? 1 : 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...byName.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
68
|
+
}
|
|
69
|
+
/** Bare URL scan over message text; trailing punctuation stripped. Backticks
|
|
70
|
+
* and ellipses excluded so markdown-wrapped or truncated URLs don't leak in. */
|
|
71
|
+
const URL_RE = /https?:\/\/[^\s"'`()<>\]\\…]+/g;
|
|
72
|
+
/** A routable host: dotted domain (optionally :port). localhost/IPs-of-one-segment
|
|
73
|
+
* and markdown garbage (`…`) are not Links-section material. */
|
|
74
|
+
const HOST_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(:\d+)?$/i;
|
|
75
|
+
function classifyLink(url) {
|
|
76
|
+
let m;
|
|
77
|
+
// Linear: https://linear.app/<workspace>/issue/RUSH-2076/slug
|
|
78
|
+
if ((m = url.match(/https?:\/\/linear\.app\/[\w-]+\/issue\/([A-Z]{2,6}-\d+)/))) {
|
|
79
|
+
return { kind: 'linear', url, label: m[1] };
|
|
80
|
+
}
|
|
81
|
+
// Jira: https://<host>.atlassian.net/browse/PROJ-123 (or /jira/browse/)
|
|
82
|
+
if ((m = url.match(/https?:\/\/[\w.-]*(?:atlassian\.net|jira[\w.-]*)\/browse\/([A-Z]{2,10}-\d+)/))) {
|
|
83
|
+
return { kind: 'jira', url, label: m[1] };
|
|
84
|
+
}
|
|
85
|
+
// GitHub: PR / issue / repo
|
|
86
|
+
if ((m = url.match(/https?:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/))) {
|
|
87
|
+
return { kind: 'github', url, label: `PR#${m[2]}` };
|
|
88
|
+
}
|
|
89
|
+
if ((m = url.match(/https?:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)/))) {
|
|
90
|
+
return { kind: 'github', url, label: `${m[1]}#${m[2]}` };
|
|
91
|
+
}
|
|
92
|
+
if ((m = url.match(/https?:\/\/github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/))) {
|
|
93
|
+
return { kind: 'github', url, label: m[1] };
|
|
94
|
+
}
|
|
95
|
+
// GitLab: MR / issue
|
|
96
|
+
if ((m = url.match(/https?:\/\/(gitlab\.com|[\w.-]*gitlab[\w.-]*)\/(.+?)\/-\/merge_requests\/(\d+)/))) {
|
|
97
|
+
return { kind: 'gitlab', url, label: `${m[2]}!${m[3]}` };
|
|
98
|
+
}
|
|
99
|
+
if ((m = url.match(/https?:\/\/(gitlab\.com|[\w.-]*gitlab[\w.-]*)\/(.+?)\/-\/issues\/(\d+)/))) {
|
|
100
|
+
return { kind: 'gitlab', url, label: `${m[2]}#${m[3]}` };
|
|
101
|
+
}
|
|
102
|
+
const host = url.match(/^https?:\/\/([^/?]+)/)?.[1];
|
|
103
|
+
if (!host || !HOST_RE.test(host))
|
|
104
|
+
return undefined;
|
|
105
|
+
return { kind: 'other', url, label: host };
|
|
106
|
+
}
|
|
107
|
+
const MAX_LINKS = 12;
|
|
108
|
+
/**
|
|
109
|
+
* Links mentioned in user/assistant messages, classified (Linear/Jira/GitHub/
|
|
110
|
+
* GitLab), deduped by URL AND by label (a session that quotes the same PR in
|
|
111
|
+
* five messages shows it once), first-seen order. Capped so a link-heavy
|
|
112
|
+
* session can't flood the pane.
|
|
113
|
+
*/
|
|
114
|
+
export function extractLinks(events) {
|
|
115
|
+
const seenUrl = new Set();
|
|
116
|
+
const seenLabel = new Set();
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const e of events) {
|
|
119
|
+
if (e.type !== 'message' || !e.content)
|
|
120
|
+
continue;
|
|
121
|
+
// Harness-injected scaffolding (bash wrappers, system reminders) carries
|
|
122
|
+
// URLs that aren't conversation references — same exclusion the rest of
|
|
123
|
+
// the pipeline applies.
|
|
124
|
+
if (e._synthetic)
|
|
125
|
+
continue;
|
|
126
|
+
for (const raw of e.content.match(URL_RE) ?? []) {
|
|
127
|
+
const url = raw.replace(/[.,;:)\]`]+$/, '');
|
|
128
|
+
if (seenUrl.has(url))
|
|
129
|
+
continue;
|
|
130
|
+
seenUrl.add(url);
|
|
131
|
+
const link = classifyLink(url);
|
|
132
|
+
if (!link || seenLabel.has(link.label))
|
|
133
|
+
continue;
|
|
134
|
+
seenLabel.add(link.label);
|
|
135
|
+
out.push(link);
|
|
136
|
+
if (out.length >= MAX_LINKS)
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
const ARTIFACT_EXT_RE = /\.(md|markdown|html?)$/i;
|
|
143
|
+
const MAX_ARTIFACTS = 12;
|
|
144
|
+
/**
|
|
145
|
+
* Documents/files the session CREATED, from the already-classified changes
|
|
146
|
+
* (callers classify once — this never re-derives). Keeps the ones a human
|
|
147
|
+
* browses later: anything under `.agents/artifacts|plans|reports/`, plus other
|
|
148
|
+
* `*.md`/`*.html` creations. Source/config churn (the bulk of `+N`) stays in
|
|
149
|
+
* the Changes line.
|
|
150
|
+
*/
|
|
151
|
+
export function extractArtifacts(changes) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const ch of changes) {
|
|
154
|
+
if (ch.op !== 'created')
|
|
155
|
+
continue;
|
|
156
|
+
const p = ch.path;
|
|
157
|
+
if (isNoisePath(p))
|
|
158
|
+
continue;
|
|
159
|
+
const norm = p.replace(/\\/g, '/');
|
|
160
|
+
let bucket;
|
|
161
|
+
if (/\/\.agents\/artifacts\//.test(norm))
|
|
162
|
+
bucket = 'artifacts';
|
|
163
|
+
else if (/\/\.agents\/plans\//.test(norm) || /\/plans\/[^/]+\.md$/i.test(norm))
|
|
164
|
+
bucket = 'plans';
|
|
165
|
+
else if (/\/\.agents\/reports\//.test(norm))
|
|
166
|
+
bucket = 'reports';
|
|
167
|
+
else if (ARTIFACT_EXT_RE.test(norm))
|
|
168
|
+
bucket = 'docs';
|
|
169
|
+
if (!bucket)
|
|
170
|
+
continue;
|
|
171
|
+
out.push({ path: p, basename: path.posix.basename(norm), bucket });
|
|
172
|
+
if (out.length >= MAX_ARTIFACTS)
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
// ── Repos ─────────────────────────────────────────────────────────────────────
|
|
178
|
+
/** Candidate dirs to probe for a `.git` root, and the walk depth per dir. */
|
|
179
|
+
const MAX_REPO_PROBES = 12;
|
|
180
|
+
const REPO_WALK_DEPTH = 6;
|
|
181
|
+
/** A walk-up that lands here overshot the workspace — never a "repo worked in". */
|
|
182
|
+
function isOvershotRoot(dir) {
|
|
183
|
+
return dir === '/' || dir === os.tmpdir() || dir === os.homedir();
|
|
184
|
+
}
|
|
185
|
+
function repoRootFrom(dir) {
|
|
186
|
+
let cur = dir;
|
|
187
|
+
for (let i = 0; i < REPO_WALK_DEPTH; i++) {
|
|
188
|
+
try {
|
|
189
|
+
if (fs.existsSync(path.join(cur, '.git'))) {
|
|
190
|
+
return isOvershotRoot(cur) ? undefined : cur;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
const parent = path.dirname(cur);
|
|
197
|
+
if (parent === cur)
|
|
198
|
+
return undefined;
|
|
199
|
+
cur = parent;
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Repos the session worked in, from the directories its file paths live under
|
|
205
|
+
* (a bounded `.git` walk-up; a `.git` FILE counts too — that's the worktree
|
|
206
|
+
* layout). Names are repo dir basenames, first-seen order, capped.
|
|
207
|
+
*
|
|
208
|
+
* Relative paths resolve against the SESSION's cwd only — when it is unknown
|
|
209
|
+
* (e.g. kimi rows carry no cwd today) they are skipped: resolving them against
|
|
210
|
+
* the viewer's process cwd attributes the session to whatever repo the CLI
|
|
211
|
+
* happens to run in, which is a wrong answer, not a degraded one.
|
|
212
|
+
*/
|
|
213
|
+
export function extractRepos(events, cwd) {
|
|
214
|
+
const candidates = [];
|
|
215
|
+
const seenCand = new Set();
|
|
216
|
+
const addCandidate = (p) => {
|
|
217
|
+
if (!p || isNoisePath(p))
|
|
218
|
+
return;
|
|
219
|
+
// DotAgents internals (the `.system` registry repo, run archives) are
|
|
220
|
+
// infrastructure, not "repos the user works in".
|
|
221
|
+
if (p.includes('/.agents/.system/') || p.includes('/.agents/.history/'))
|
|
222
|
+
return;
|
|
223
|
+
if (!path.isAbsolute(p) && !cwd)
|
|
224
|
+
return;
|
|
225
|
+
const abs = path.isAbsolute(p) ? p : path.resolve(cwd, p);
|
|
226
|
+
const dir = path.dirname(abs);
|
|
227
|
+
if (seenCand.has(dir))
|
|
228
|
+
return;
|
|
229
|
+
seenCand.add(dir);
|
|
230
|
+
candidates.push(dir);
|
|
231
|
+
};
|
|
232
|
+
for (const e of events) {
|
|
233
|
+
if (e.type !== 'tool_use' || e._local)
|
|
234
|
+
continue;
|
|
235
|
+
const p = e.path || e.args?.file_path || e.args?.path || '';
|
|
236
|
+
if (typeof p === 'string' && p)
|
|
237
|
+
addCandidate(p);
|
|
238
|
+
if (candidates.length >= MAX_REPO_PROBES)
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
const repos = [];
|
|
242
|
+
const seenRoot = new Set();
|
|
243
|
+
for (const dir of candidates) {
|
|
244
|
+
const root = repoRootFrom(dir);
|
|
245
|
+
if (!root || seenRoot.has(root))
|
|
246
|
+
continue;
|
|
247
|
+
seenRoot.add(root);
|
|
248
|
+
repos.push(path.basename(root));
|
|
249
|
+
}
|
|
250
|
+
return repos;
|
|
251
|
+
}
|
|
@@ -66,6 +66,10 @@ function sanitizeEvent(e) {
|
|
|
66
66
|
e.model = sanitizeForTerminal(e.model);
|
|
67
67
|
if (e.mediaType)
|
|
68
68
|
e.mediaType = sanitizeForTerminal(e.mediaType);
|
|
69
|
+
if (e.hookName)
|
|
70
|
+
e.hookName = sanitizeForTerminal(e.hookName);
|
|
71
|
+
if (e.hookEvent)
|
|
72
|
+
e.hookEvent = sanitizeForTerminal(e.hookEvent);
|
|
69
73
|
if (e.args)
|
|
70
74
|
e.args = sanitizeArgsDeep(e.args);
|
|
71
75
|
}
|
|
@@ -484,7 +488,25 @@ export function parseClaudeContent(content) {
|
|
|
484
488
|
content: raw.subtype || 'success',
|
|
485
489
|
});
|
|
486
490
|
}
|
|
487
|
-
|
|
491
|
+
else if (type === 'attachment') {
|
|
492
|
+
// Hook firings are recorded as attachments: `hook_success` / `hook_error` /
|
|
493
|
+
// `hook_blocked` per firing, plus a derivative `hook_additional_context`
|
|
494
|
+
// record for the SAME firing (shared toolUseID) — skip the derivative or
|
|
495
|
+
// every firing counts twice.
|
|
496
|
+
const att = raw.attachment;
|
|
497
|
+
const attType = att?.type;
|
|
498
|
+
if (typeof attType === 'string' && attType.startsWith('hook_') && attType !== 'hook_additional_context') {
|
|
499
|
+
events.push({
|
|
500
|
+
type: 'hook',
|
|
501
|
+
agent: 'claude',
|
|
502
|
+
timestamp,
|
|
503
|
+
hookName: typeof att.hookName === 'string' ? att.hookName : undefined,
|
|
504
|
+
hookEvent: typeof att.hookEvent === 'string' ? att.hookEvent : undefined,
|
|
505
|
+
success: attType === 'hook_success',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// Skip: permission-mode, non-hook attachments, and other line types
|
|
488
510
|
}
|
|
489
511
|
return events;
|
|
490
512
|
}
|
|
@@ -5,3 +5,17 @@
|
|
|
5
5
|
* `remote.ts`, so a back-import would cycle.
|
|
6
6
|
*/
|
|
7
7
|
export declare function formatRelativeTime(isoTimestamp: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
|
|
10
|
+
* milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
|
|
11
|
+
* `--unused` on `secrets list`.
|
|
12
|
+
*
|
|
13
|
+
* It lives here, in a module with no imports, rather than in `discover.ts` where
|
|
14
|
+
* it started: `discover.ts` loads `../sqlite.js`, so importing this one function
|
|
15
|
+
* from there pulls `node:sqlite` into the caller's module graph and Node prints
|
|
16
|
+
* `ExperimentalWarning: SQLite …` on stderr. That is invisible in a command that
|
|
17
|
+
* already touches the session DB, and a broken contract in one that doesn't —
|
|
18
|
+
* `monitors --json` asserts a clean stderr. `discover.ts` re-exports it, so
|
|
19
|
+
* existing importers are unaffected.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseTimeFilter(input: string): number;
|
|
@@ -30,3 +30,39 @@ export function formatRelativeTime(isoTimestamp) {
|
|
|
30
30
|
? label
|
|
31
31
|
: `${label} '${String(d.getFullYear()).slice(-2)}`;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
|
|
35
|
+
* milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
|
|
36
|
+
* `--unused` on `secrets list`.
|
|
37
|
+
*
|
|
38
|
+
* It lives here, in a module with no imports, rather than in `discover.ts` where
|
|
39
|
+
* it started: `discover.ts` loads `../sqlite.js`, so importing this one function
|
|
40
|
+
* from there pulls `node:sqlite` into the caller's module graph and Node prints
|
|
41
|
+
* `ExperimentalWarning: SQLite …` on stderr. That is invisible in a command that
|
|
42
|
+
* already touches the session DB, and a broken contract in one that doesn't —
|
|
43
|
+
* `monitors --json` asserts a clean stderr. `discover.ts` re-exports it, so
|
|
44
|
+
* existing importers are unaffected.
|
|
45
|
+
*/
|
|
46
|
+
export function parseTimeFilter(input) {
|
|
47
|
+
// Units: m=minute, h=hour, d=day, w=week, mo=month(30d), y=year(365d). `mo`
|
|
48
|
+
// must precede the single-letter alternatives so "1mo" isn't read as "1m"+"o".
|
|
49
|
+
const relativeMatch = input.match(/^(\d+)(mo|[mhdwy])$/i);
|
|
50
|
+
if (relativeMatch) {
|
|
51
|
+
const value = parseInt(relativeMatch[1], 10);
|
|
52
|
+
const unit = relativeMatch[2].toLowerCase();
|
|
53
|
+
if (unit === 'm')
|
|
54
|
+
return Date.now() - value * 60_000;
|
|
55
|
+
if (unit === 'h')
|
|
56
|
+
return Date.now() - value * 3_600_000;
|
|
57
|
+
if (unit === 'd')
|
|
58
|
+
return Date.now() - value * 86_400_000;
|
|
59
|
+
if (unit === 'w')
|
|
60
|
+
return Date.now() - value * 7 * 86_400_000;
|
|
61
|
+
if (unit === 'mo')
|
|
62
|
+
return Date.now() - value * 30 * 86_400_000;
|
|
63
|
+
if (unit === 'y')
|
|
64
|
+
return Date.now() - value * 365 * 86_400_000;
|
|
65
|
+
}
|
|
66
|
+
const ts = new Date(input).getTime();
|
|
67
|
+
return Number.isNaN(ts) ? 0 : ts;
|
|
68
|
+
}
|
|
@@ -11,6 +11,13 @@ import type { SessionEvent, SessionMeta } from './types.js';
|
|
|
11
11
|
* Return absPath relative to cwd; fall back to ~/… then absolute.
|
|
12
12
|
*/
|
|
13
13
|
export declare function relativeToCwd(absPath: string, cwd?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Display form for a touched path: cwd-relative first; then collapse any
|
|
16
|
+
* `.agents/worktrees/<slug>` segment (in-cwd OR outside) to `⧉ <slug>/…` so
|
|
17
|
+
* group labels stay on one line instead of `~/src/…/.agents/worktrees/<slug>/…`;
|
|
18
|
+
* else home-collapse.
|
|
19
|
+
*/
|
|
20
|
+
export declare function displayPath(absPath: string, cwd?: string): string;
|
|
14
21
|
/**
|
|
15
22
|
* Wrap a filesystem path label in an OSC 8 `file://` hyperlink when the terminal
|
|
16
23
|
* supports it. Degrades to the plain label otherwise.
|
|
@@ -13,6 +13,7 @@ import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
|
|
|
13
13
|
import { renderMarkdown } from '../markdown.js';
|
|
14
14
|
import { redactSecrets } from '../redact.js';
|
|
15
15
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
|
|
16
|
+
import { extractArtifacts, extractHooks, extractLinks, extractSkills } from './highlights.js';
|
|
16
17
|
import { extractTodoProgressFromEvents } from './state.js';
|
|
17
18
|
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
18
19
|
/**
|
|
@@ -29,6 +30,31 @@ export function relativeToCwd(absPath, cwd) {
|
|
|
29
30
|
}
|
|
30
31
|
return absPath;
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Display form for a touched path: cwd-relative first; then collapse any
|
|
35
|
+
* `.agents/worktrees/<slug>` segment (in-cwd OR outside) to `⧉ <slug>/…` so
|
|
36
|
+
* group labels stay on one line instead of `~/src/…/.agents/worktrees/<slug>/…`;
|
|
37
|
+
* else home-collapse.
|
|
38
|
+
*/
|
|
39
|
+
export function displayPath(absPath, cwd) {
|
|
40
|
+
const rel = relativeToCwd(absPath, cwd);
|
|
41
|
+
const norm = rel.replace(/\\/g, '/');
|
|
42
|
+
const wt = norm.match(/(^|\/)\.agents\/worktrees\/([^/]+)/);
|
|
43
|
+
if (wt) {
|
|
44
|
+
const after = norm.slice(norm.indexOf(wt[0]) + wt[0].length).replace(/^\//, '');
|
|
45
|
+
return after ? `⧉ ${wt[2]}/${after}` : `⧉ ${wt[2]}`;
|
|
46
|
+
}
|
|
47
|
+
return rel;
|
|
48
|
+
}
|
|
49
|
+
/** One checklist line with a status marker: `[x] done` / `[>] doing` / `[ ] todo`. */
|
|
50
|
+
function renderTodoMarker(item) {
|
|
51
|
+
const text = item.content;
|
|
52
|
+
if (item.status === 'completed')
|
|
53
|
+
return chalk.green('[x]') + ' ' + chalk.gray(text);
|
|
54
|
+
if (item.status === 'in_progress')
|
|
55
|
+
return chalk.yellow('[>]') + ' ' + chalk.white(text);
|
|
56
|
+
return chalk.gray('[ ]') + ' ' + chalk.white(text);
|
|
57
|
+
}
|
|
32
58
|
/** Best-effort feature-detect for OSC 8 hyperlink support in the current TTY. */
|
|
33
59
|
function supportsHyperlinks() {
|
|
34
60
|
return Boolean(process.stdout.isTTY &&
|
|
@@ -387,11 +413,12 @@ function pickDistinct(samples, max) {
|
|
|
387
413
|
return result.length > 0 ? result : samples.slice(0, max);
|
|
388
414
|
}
|
|
389
415
|
// ── File grouping ─────────────────────────────────────────────────────────────
|
|
390
|
-
/** Group file paths by their parent directory,
|
|
416
|
+
/** Group file paths by their parent directory, in display form (cwd-relative,
|
|
417
|
+
* worktree-collapsed, home-collapsed). */
|
|
391
418
|
function groupByParentDir(paths, cwd) {
|
|
392
419
|
const groups = new Map();
|
|
393
420
|
for (const p of paths) {
|
|
394
|
-
const rel =
|
|
421
|
+
const rel = displayPath(p, cwd);
|
|
395
422
|
const slashIdx = rel.lastIndexOf('/');
|
|
396
423
|
const dir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '.';
|
|
397
424
|
const base = slashIdx >= 0 ? rel.slice(slashIdx + 1) : rel;
|
|
@@ -454,17 +481,17 @@ const OP_MARK = { created: '+', modified: '~', deleted: '−' };
|
|
|
454
481
|
* create/modify/delete lifecycle, plus a `+N ~N −N` summary. Replaces the old
|
|
455
482
|
* flat "Modified" list. Returns true if anything was rendered.
|
|
456
483
|
*/
|
|
457
|
-
function renderChangesSection(lines,
|
|
484
|
+
function renderChangesSection(lines, allChanges, cwd) {
|
|
458
485
|
// In-project changes only; edits outside cwd (e.g. /tmp) keep their own
|
|
459
486
|
// "External edits" section so they don't clutter the project's changeset.
|
|
460
487
|
const inCwd = (p) => !cwd || !p.startsWith('/') || p.startsWith(cwd + '/');
|
|
461
|
-
const changes =
|
|
488
|
+
const changes = allChanges.filter(ch => inCwd(ch.path));
|
|
462
489
|
if (changes.length === 0)
|
|
463
490
|
return false;
|
|
464
491
|
const c = changeCounts(changes);
|
|
465
492
|
const opByRel = new Map();
|
|
466
493
|
for (const ch of changes)
|
|
467
|
-
opByRel.set(
|
|
494
|
+
opByRel.set(displayPath(ch.path, cwd), ch.op);
|
|
468
495
|
const summary = [
|
|
469
496
|
c.created ? chalk.green(`+${c.created}`) : '',
|
|
470
497
|
c.modified ? chalk.yellow(`~${c.modified}`) : '',
|
|
@@ -528,8 +555,8 @@ export function renderSummary(events, cwd) {
|
|
|
528
555
|
const filesModifiedExternal = new Set();
|
|
529
556
|
// Commands with timestamps
|
|
530
557
|
const cmdList = [];
|
|
531
|
-
// Plan items
|
|
532
|
-
const todoItems = extractTodoProgressFromEvents(events)?.items
|
|
558
|
+
// Plan items (checklist entries keep their status for [x]/[>]/[ ] markers)
|
|
559
|
+
const todoItems = extractTodoProgressFromEvents(events)?.items ?? [];
|
|
533
560
|
let exitPlanContent = null;
|
|
534
561
|
let planFilePath = null;
|
|
535
562
|
// Subagent spawns
|
|
@@ -559,7 +586,7 @@ export function renderSummary(events, cwd) {
|
|
|
559
586
|
}
|
|
560
587
|
else {
|
|
561
588
|
(isInsideCwd(p) || !cwd ? filesModifiedAbs : filesModifiedExternal).add(p);
|
|
562
|
-
recentActivity.push({ kind: 'edit', label:
|
|
589
|
+
recentActivity.push({ kind: 'edit', label: displayPath(p, cwd), ts, absPath: p });
|
|
563
590
|
}
|
|
564
591
|
}
|
|
565
592
|
}
|
|
@@ -624,11 +651,11 @@ export function renderSummary(events, cwd) {
|
|
|
624
651
|
filesReadAbs.delete(p);
|
|
625
652
|
for (const p of filesModifiedExternal)
|
|
626
653
|
filesReadAbs.delete(p);
|
|
627
|
-
// Build abs→
|
|
654
|
+
// Build abs→display mapping for linkPath
|
|
628
655
|
const buildAbsMap = (absSet) => {
|
|
629
656
|
const m = new Map();
|
|
630
657
|
for (const abs of absSet) {
|
|
631
|
-
const rel =
|
|
658
|
+
const rel = displayPath(abs, cwd);
|
|
632
659
|
m.set(rel, abs);
|
|
633
660
|
}
|
|
634
661
|
return m;
|
|
@@ -668,22 +695,27 @@ export function renderSummary(events, cwd) {
|
|
|
668
695
|
}
|
|
669
696
|
lines.push('');
|
|
670
697
|
}
|
|
671
|
-
// 3. Plan
|
|
698
|
+
// 3. Plan — the plan document (ExitPlanMode text / plan file) AND the live
|
|
699
|
+
// checklist. Both render: the checklist used to be hidden whenever plan text
|
|
700
|
+
// existed, which read as "this session had no todos".
|
|
672
701
|
if (todoItems.length > 0 || exitPlanContent || planFilePath) {
|
|
673
702
|
lines.push(chalk.bold('Plan'));
|
|
674
703
|
if (planFilePath) {
|
|
675
704
|
const home = process.env.HOME ?? '';
|
|
676
|
-
const
|
|
677
|
-
lines.push(' ' + chalk.cyan(
|
|
705
|
+
const planPathDisplay = home && planFilePath.startsWith(home) ? planFilePath.replace(home, '~') : planFilePath;
|
|
706
|
+
lines.push(' ' + chalk.cyan(planPathDisplay));
|
|
678
707
|
}
|
|
679
708
|
if (exitPlanContent) {
|
|
680
709
|
const planLines = exitPlanContent.split('\n').slice(0, 10);
|
|
681
710
|
for (const l of planLines)
|
|
682
711
|
lines.push(' ' + l);
|
|
683
712
|
}
|
|
684
|
-
|
|
713
|
+
if (todoItems.length > 0) {
|
|
685
714
|
for (const item of todoItems.slice(0, 20)) {
|
|
686
|
-
lines.push('
|
|
715
|
+
lines.push(' ' + renderTodoMarker(item));
|
|
716
|
+
}
|
|
717
|
+
if (todoItems.length > 20) {
|
|
718
|
+
lines.push(' ' + chalk.gray(`… (${todoItems.length - 20} more)`));
|
|
687
719
|
}
|
|
688
720
|
}
|
|
689
721
|
lines.push('');
|
|
@@ -698,6 +730,30 @@ export function renderSummary(events, cwd) {
|
|
|
698
730
|
}
|
|
699
731
|
lines.push('');
|
|
700
732
|
}
|
|
733
|
+
// 4b. Highlights — what the session USED (skills, hooks) and the references
|
|
734
|
+
// it mentioned (links). Shared extraction with the picker preview so the two
|
|
735
|
+
// renders never disagree.
|
|
736
|
+
const skills = extractSkills(events);
|
|
737
|
+
if (skills.length > 0) {
|
|
738
|
+
const shown = skills.map(s => chalk.white(s.name) + (s.count > 1 ? chalk.gray(` ×${s.count}`) : ''));
|
|
739
|
+
lines.push(chalk.bold('Skills') + chalk.gray(` (${skills.length})`) + ' ' + shown.join(chalk.gray(' · ')));
|
|
740
|
+
lines.push('');
|
|
741
|
+
}
|
|
742
|
+
const hooks = extractHooks(events);
|
|
743
|
+
if (hooks.length > 0) {
|
|
744
|
+
const shown = hooks.map(h => chalk.white(h.name) + (h.count > 1 ? chalk.gray(` ×${h.count}`) : '') + (h.failed ? chalk.red(` (${h.failed} failed)`) : ''));
|
|
745
|
+
lines.push(chalk.bold('Hooks') + chalk.gray(` (${hooks.length})`) + ' ' + shown.join(chalk.gray(' · ')));
|
|
746
|
+
lines.push('');
|
|
747
|
+
}
|
|
748
|
+
const links = extractLinks(events);
|
|
749
|
+
if (links.length > 0) {
|
|
750
|
+
// Width-capped like the picker's Dirs line: a link-heavy session must not
|
|
751
|
+
// wrap the summary pane.
|
|
752
|
+
const shown = links.slice(0, 6).map(l => chalk.blue(linkUrl(l.url, l.label)));
|
|
753
|
+
const more = links.length > 6 ? chalk.gray(` · +${links.length - 6} more`) : '';
|
|
754
|
+
lines.push(chalk.bold('Links') + chalk.gray(` (${links.length})`) + ' ' + shown.join(chalk.gray(' · ')) + more);
|
|
755
|
+
lines.push('');
|
|
756
|
+
}
|
|
701
757
|
// 5. Errors (moved up from the bottom: it describes failed attempts, not the
|
|
702
758
|
// session's final state. Sitting at the bottom previously made early errors
|
|
703
759
|
// look recent, which confused readers.)
|
|
@@ -713,8 +769,22 @@ export function renderSummary(events, cwd) {
|
|
|
713
769
|
lines.push('');
|
|
714
770
|
}
|
|
715
771
|
// 6. Changes — files grouped by directory with create/modify/delete lifecycle
|
|
716
|
-
// (replaces the old flat "Modified" + "External edits" lists).
|
|
717
|
-
|
|
772
|
+
// (replaces the old flat "Modified" + "External edits" lists). Classified
|
|
773
|
+
// once here and shared with the Artifacts section below.
|
|
774
|
+
const allChanges = classifyFileChanges(events);
|
|
775
|
+
renderChangesSection(lines, allChanges, cwd);
|
|
776
|
+
// 6a. Artifacts — documents the session PRODUCED (`.agents/artifacts|plans|
|
|
777
|
+
// reports`, other *.md/*.html creations), named and clickable. These drown in
|
|
778
|
+
// the Changeset's source churn, so they get their own section.
|
|
779
|
+
const artifacts = extractArtifacts(allChanges);
|
|
780
|
+
if (artifacts.length > 0) {
|
|
781
|
+
lines.push(chalk.bold('Artifacts') + chalk.gray(` (${artifacts.length})`));
|
|
782
|
+
for (const a of artifacts) {
|
|
783
|
+
const tag = a.bucket === 'docs' ? '' : chalk.gray(` (${a.bucket})`);
|
|
784
|
+
lines.push(' ' + chalk.green('+') + ' ' + chalk.cyan(linkPath(a.path, a.basename)) + tag);
|
|
785
|
+
}
|
|
786
|
+
lines.push('');
|
|
787
|
+
}
|
|
718
788
|
// 6b. Catch-up signals: last test/build verdict, then the tool histogram.
|
|
719
789
|
renderTestsLine(lines, events);
|
|
720
790
|
renderToolsSection(lines, computeSummaryStats(events));
|
|
@@ -18,7 +18,7 @@ export declare const SESSION_AGENTS: SessionAgentId[];
|
|
|
18
18
|
export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
|
|
19
19
|
/** A single normalized event within a session (message, tool call, thinking, etc.). */
|
|
20
20
|
export interface SessionEvent {
|
|
21
|
-
type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment';
|
|
21
|
+
type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook';
|
|
22
22
|
agent: SessionAgentId;
|
|
23
23
|
timestamp: string;
|
|
24
24
|
role?: 'user' | 'assistant';
|
|
@@ -49,6 +49,9 @@ export interface SessionEvent {
|
|
|
49
49
|
name?: string;
|
|
50
50
|
mediaType?: string;
|
|
51
51
|
sizeBytes?: number;
|
|
52
|
+
hookName?: string;
|
|
53
|
+
/** Lifecycle event the hook fired on (SessionStart, PreToolUse, …). */
|
|
54
|
+
hookEvent?: string;
|
|
52
55
|
}
|
|
53
56
|
/** A displayable file attachment discovered in a session transcript. */
|
|
54
57
|
export interface SessionAttachment {
|
|
@@ -70,6 +70,7 @@ export declare const loadDrive: ModuleLoader;
|
|
|
70
70
|
export declare const loadFactory: ModuleLoader;
|
|
71
71
|
export declare const loadUsage: ModuleLoader;
|
|
72
72
|
export declare const loadCost: ModuleLoader;
|
|
73
|
+
export declare const loadPerf: ModuleLoader;
|
|
73
74
|
export declare const loadOutput: ModuleLoader;
|
|
74
75
|
export declare const loadBudget: ModuleLoader;
|
|
75
76
|
export declare const loadAlias: ModuleLoader;
|