@phnx-labs/agents-cli 1.20.89 → 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 +361 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/feed.js +77 -4
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- 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/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +58 -30
- package/dist/commands/secrets.d.ts +52 -4
- package/dist/commands/secrets.js +234 -37
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions-picker.js +70 -1
- package/dist/commands/sessions.d.ts +103 -20
- package/dist/commands/sessions.js +356 -62
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/index.js +18 -3
- package/dist/lib/activity.d.ts +11 -1
- package/dist/lib/activity.js +1 -0
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- 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/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- 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/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- 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/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -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/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/relative-time.d.ts +14 -0
- package/dist/lib/session/relative-time.js +36 -0
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- 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/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- package/package.json +3 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Favorited (starred) sessions — the durable "keep this one handy" mark a human
|
|
3
|
+
* puts on a session, deliberately kept OUT of the session index.
|
|
4
|
+
*
|
|
5
|
+
* `sessions.db` is a rebuildable CACHE: a reindex or a schema bump throws its
|
|
6
|
+
* rows away and re-derives them from the transcripts on disk. A favorite is not
|
|
7
|
+
* derivable from a transcript — it is a human's choice — so a column there would
|
|
8
|
+
* be silently lost on the next rebuild. It lives in `~/.agents/.history/` instead,
|
|
9
|
+
* next to the actor sidecars, which is never pruned.
|
|
10
|
+
*
|
|
11
|
+
* One flat set of session ids. The id is the transcript's own uuid, so it is
|
|
12
|
+
* stable and machine-independent — but the file is NOT synced today: session sync
|
|
13
|
+
* carries `.history/backups/` (`lib/session/sync/agents.ts`), not this. Favorites
|
|
14
|
+
* are therefore per-machine; carrying them across the fleet would mean adding
|
|
15
|
+
* them to the sync manifest, which this does not do.
|
|
16
|
+
*
|
|
17
|
+
* Reads are memoized against the file's mtime — the picker asks `isFavorite` once
|
|
18
|
+
* per rendered row, and re-reading a JSON file per row on every keystroke is the
|
|
19
|
+
* kind of cost that makes a TUI feel broken.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { getHistoryDir } from '../state.js';
|
|
24
|
+
export function favoritesFilePath() {
|
|
25
|
+
return path.join(getHistoryDir(), 'favorites.json');
|
|
26
|
+
}
|
|
27
|
+
/** Memoized parse, invalidated by the file's mtime+size (another process — or
|
|
28
|
+
* another machine's sync — can rewrite it under us). */
|
|
29
|
+
let cache = null;
|
|
30
|
+
function statKey(file) {
|
|
31
|
+
try {
|
|
32
|
+
const st = fs.statSync(file);
|
|
33
|
+
return `${st.mtimeMs}:${st.size}`;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return 'absent';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Drop the memoized read. Tests that write the file directly need this; nothing
|
|
40
|
+
* in the CLI does, because every mutation here refreshes the cache itself. */
|
|
41
|
+
export function clearFavoritesCache() {
|
|
42
|
+
cache = null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every favorited session id. Empty (never throws) when the file is absent,
|
|
46
|
+
* unreadable, or malformed — a corrupt favorites file must not take down
|
|
47
|
+
* `agents sessions`.
|
|
48
|
+
*/
|
|
49
|
+
export function listFavorites() {
|
|
50
|
+
const file = favoritesFilePath();
|
|
51
|
+
const key = statKey(file);
|
|
52
|
+
if (cache && cache.key === key)
|
|
53
|
+
return cache.ids;
|
|
54
|
+
let ids = new Set();
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
57
|
+
if (Array.isArray(parsed?.sessionIds)) {
|
|
58
|
+
ids = new Set(parsed.sessionIds.filter((id) => typeof id === 'string' && id.length > 0));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// absent / unreadable / malformed — an empty set is the honest answer
|
|
63
|
+
}
|
|
64
|
+
cache = { key, ids };
|
|
65
|
+
return ids;
|
|
66
|
+
}
|
|
67
|
+
export function isFavorite(sessionId) {
|
|
68
|
+
if (!sessionId)
|
|
69
|
+
return false;
|
|
70
|
+
return listFavorites().has(sessionId);
|
|
71
|
+
}
|
|
72
|
+
/** Atomic write (tmp + rename) so a concurrent reader never sees a half file. */
|
|
73
|
+
function writeFavorites(ids) {
|
|
74
|
+
const file = favoritesFilePath();
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
const body = { version: 1, sessionIds: [...ids].sort() };
|
|
77
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
78
|
+
fs.writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n');
|
|
79
|
+
fs.renameSync(tmp, file);
|
|
80
|
+
cache = { key: statKey(file), ids };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Set (or clear) the favorite mark on a session. Returns the resulting state, so
|
|
84
|
+
* a caller can report it without a second read. A no-op write is skipped, which
|
|
85
|
+
* keeps the file's mtime — and every other process's memoized read — untouched.
|
|
86
|
+
*/
|
|
87
|
+
export function setFavorite(sessionId, on) {
|
|
88
|
+
const ids = new Set(listFavorites());
|
|
89
|
+
if (ids.has(sessionId) === on)
|
|
90
|
+
return on;
|
|
91
|
+
if (on)
|
|
92
|
+
ids.add(sessionId);
|
|
93
|
+
else
|
|
94
|
+
ids.delete(sessionId);
|
|
95
|
+
writeFavorites(ids);
|
|
96
|
+
return on;
|
|
97
|
+
}
|
|
98
|
+
/** Flip the mark; returns the new state (`true` = now favorited). */
|
|
99
|
+
export function toggleFavorite(sessionId) {
|
|
100
|
+
return setFavorite(sessionId, !isFavorite(sessionId));
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host link — whether anything is still on the other end of a live session.
|
|
3
|
+
*
|
|
4
|
+
* Every liveness signal the scan already has answers "is the AGENT process
|
|
5
|
+
* alive". None of them answer "is anyone still driving it", and those come apart
|
|
6
|
+
* in exactly the two ways a user notices:
|
|
7
|
+
*
|
|
8
|
+
* - **The host program died and took the agent with it.** VS Code / the editor
|
|
9
|
+
* window crashes or the SSH connection drops, so the agent process dies with
|
|
10
|
+
* its parent, but the window never got to run its teardown — its slice of
|
|
11
|
+
* `live-terminals.json` is left behind, stale, still naming a dead pid. Today
|
|
12
|
+
* that entry is filtered out at read time, so the session simply VANISHES
|
|
13
|
+
* from `--active` instead of reporting that it fell over. That is `host-gone`.
|
|
14
|
+
*
|
|
15
|
+
* - **The host program died and the agent survived it.** The agent was hosted
|
|
16
|
+
* in tmux (or otherwise reparented), so it keeps running with zero clients
|
|
17
|
+
* attached: still burning tokens, or sitting on a question nobody will ever
|
|
18
|
+
* answer, with no window anywhere showing it. That is `no-client`.
|
|
19
|
+
*
|
|
20
|
+
* Both are DERIVED, never asserted — from the agent pid, the owning window's
|
|
21
|
+
* keepalive, and tmux's own attached-client count. A deliberately backgrounded
|
|
22
|
+
* session (`agents sessions detach`) is excluded by construction: it is supposed
|
|
23
|
+
* to have no client, so calling it orphaned would be a false alarm on the one
|
|
24
|
+
* case the user asked for.
|
|
25
|
+
*/
|
|
26
|
+
/** How a session is connected to the client that should be driving it. */
|
|
27
|
+
export type HostLink =
|
|
28
|
+
/** A client is attached, or nothing indicates otherwise. The normal case. */
|
|
29
|
+
'connected'
|
|
30
|
+
/** Alive, but nothing is viewing it — the host window is gone and the agent outlived it. */
|
|
31
|
+
| 'no-client'
|
|
32
|
+
/** The host window is gone AND the agent process died with it — an unclean exit. */
|
|
33
|
+
| 'host-gone';
|
|
34
|
+
/**
|
|
35
|
+
* How long an IDE window's registry slice may go without a refresh before we
|
|
36
|
+
* treat that window as gone. The Factory extension force-republishes its slice
|
|
37
|
+
* every 4 minutes (`KEEPALIVE_FORCE_MS` in `apps/factory/src/vscode/foreman.registry.ts`)
|
|
38
|
+
* and on every terminal open/close, so a slice this old means the window is no
|
|
39
|
+
* longer running — it is not merely quiet. Deliberately the same 10 minutes the
|
|
40
|
+
* extension itself uses to garbage-collect a peer window's slice, so the CLI and
|
|
41
|
+
* the extension agree on when a window is dead.
|
|
42
|
+
*/
|
|
43
|
+
export declare const HOST_HEARTBEAT_STALE_MS: number;
|
|
44
|
+
export interface HostLinkInput {
|
|
45
|
+
/** The agent process is still alive (already pid-reuse-checked by the caller). */
|
|
46
|
+
pidAlive: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* When the owning IDE window last refreshed its slice of the live-terminals
|
|
49
|
+
* registry. Absent for a session no IDE window owns (a bare terminal, a team
|
|
50
|
+
* spawn, a cloud task) — those have no window whose death we could observe.
|
|
51
|
+
*/
|
|
52
|
+
windowHeartbeatMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Clients attached to this session's tmux session (`#{session_attached}`).
|
|
55
|
+
* Absent when the session is not tmux-hosted, which is NOT the same as zero:
|
|
56
|
+
* zero is a positive "nobody is looking", absent is "we cannot tell".
|
|
57
|
+
*/
|
|
58
|
+
tmuxClients?: number;
|
|
59
|
+
/** The session was deliberately backgrounded — `presence` is `background`/`parked`. */
|
|
60
|
+
deliberatelyDetached?: boolean;
|
|
61
|
+
nowMs?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Classify a live row's host link. Pure — every signal is passed in, so the
|
|
65
|
+
* whole decision table is unit-testable without a process table, a tmux server,
|
|
66
|
+
* or a running editor.
|
|
67
|
+
*/
|
|
68
|
+
export declare function classifyHostLink(input: HostLinkInput): HostLink;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host link — whether anything is still on the other end of a live session.
|
|
3
|
+
*
|
|
4
|
+
* Every liveness signal the scan already has answers "is the AGENT process
|
|
5
|
+
* alive". None of them answer "is anyone still driving it", and those come apart
|
|
6
|
+
* in exactly the two ways a user notices:
|
|
7
|
+
*
|
|
8
|
+
* - **The host program died and took the agent with it.** VS Code / the editor
|
|
9
|
+
* window crashes or the SSH connection drops, so the agent process dies with
|
|
10
|
+
* its parent, but the window never got to run its teardown — its slice of
|
|
11
|
+
* `live-terminals.json` is left behind, stale, still naming a dead pid. Today
|
|
12
|
+
* that entry is filtered out at read time, so the session simply VANISHES
|
|
13
|
+
* from `--active` instead of reporting that it fell over. That is `host-gone`.
|
|
14
|
+
*
|
|
15
|
+
* - **The host program died and the agent survived it.** The agent was hosted
|
|
16
|
+
* in tmux (or otherwise reparented), so it keeps running with zero clients
|
|
17
|
+
* attached: still burning tokens, or sitting on a question nobody will ever
|
|
18
|
+
* answer, with no window anywhere showing it. That is `no-client`.
|
|
19
|
+
*
|
|
20
|
+
* Both are DERIVED, never asserted — from the agent pid, the owning window's
|
|
21
|
+
* keepalive, and tmux's own attached-client count. A deliberately backgrounded
|
|
22
|
+
* session (`agents sessions detach`) is excluded by construction: it is supposed
|
|
23
|
+
* to have no client, so calling it orphaned would be a false alarm on the one
|
|
24
|
+
* case the user asked for.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* How long an IDE window's registry slice may go without a refresh before we
|
|
28
|
+
* treat that window as gone. The Factory extension force-republishes its slice
|
|
29
|
+
* every 4 minutes (`KEEPALIVE_FORCE_MS` in `apps/factory/src/vscode/foreman.registry.ts`)
|
|
30
|
+
* and on every terminal open/close, so a slice this old means the window is no
|
|
31
|
+
* longer running — it is not merely quiet. Deliberately the same 10 minutes the
|
|
32
|
+
* extension itself uses to garbage-collect a peer window's slice, so the CLI and
|
|
33
|
+
* the extension agree on when a window is dead.
|
|
34
|
+
*/
|
|
35
|
+
export const HOST_HEARTBEAT_STALE_MS = 10 * 60_000;
|
|
36
|
+
/**
|
|
37
|
+
* Classify a live row's host link. Pure — every signal is passed in, so the
|
|
38
|
+
* whole decision table is unit-testable without a process table, a tmux server,
|
|
39
|
+
* or a running editor.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyHostLink(input) {
|
|
42
|
+
const now = input.nowMs ?? Date.now();
|
|
43
|
+
// A session detached on purpose has no client BY DESIGN. It is the one case
|
|
44
|
+
// that looks identical to an orphan from the outside, so it is excluded first —
|
|
45
|
+
// otherwise every `agents sessions detach` would raise a false alarm.
|
|
46
|
+
if (input.deliberatelyDetached)
|
|
47
|
+
return 'connected';
|
|
48
|
+
const windowGone = input.windowHeartbeatMs !== undefined && now - input.windowHeartbeatMs >= HOST_HEARTBEAT_STALE_MS;
|
|
49
|
+
// The host window stopped keeping its registry slice alive AND the agent it
|
|
50
|
+
// owned is dead: the pair went down together without teardown.
|
|
51
|
+
if (windowGone && !input.pidAlive)
|
|
52
|
+
return 'host-gone';
|
|
53
|
+
if (!input.pidAlive)
|
|
54
|
+
return 'connected'; // a plain dead pid is `closed`, not an orphan
|
|
55
|
+
// Alive with tmux reporting zero attached clients: nobody is watching it. This
|
|
56
|
+
// is the authoritative signal — tmux knows exactly how many clients it has.
|
|
57
|
+
if (input.tmuxClients === 0)
|
|
58
|
+
return 'no-client';
|
|
59
|
+
// Alive, not tmux-hosted (or tmux says someone is attached), but the window
|
|
60
|
+
// that owned it is gone. The agent outlived its editor.
|
|
61
|
+
if (windowGone)
|
|
62
|
+
return 'no-client';
|
|
63
|
+
return 'connected';
|
|
64
|
+
}
|
|
@@ -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
|
}
|