agenthud 0.9.3 → 0.10.0

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/README.md CHANGED
@@ -4,10 +4,16 @@
4
4
  [![CI](https://github.com/neochoon/agenthud/actions/workflows/ci.yml/badge.svg)](https://github.com/neochoon/agenthud/actions/workflows/ci.yml)
5
5
  [![codecov](https://codecov.io/gh/neochoon/agenthud/branch/main/graph/badge.svg)](https://codecov.io/gh/neochoon/agenthud)
6
6
 
7
- When working with AI coding agents like Claude Code, you lose visibility into what's happening across sessions. **AgentHUD** gives you a live session browser in a separate terminal see every session, sub-agent, and activity as it happens.
7
+ An observability layer for [Claude Code](https://github.com/anthropics/claude-code). **See** your live sessions, **export** structured activity logs, and **summarize** a day or a week into an LLM digest all from one CLI.
8
8
 
9
9
  ![demo](./output960.gif)
10
10
 
11
+ AgentHUD reads Claude Code's session files from `~/.claude/projects/` and gives you three things:
12
+
13
+ - **Live monitor** ([`agenthud`](#live-monitor)) — a split-view TUI showing every project, session, sub-agent, and activity as it happens.
14
+ - **Structured export** ([`agenthud report`](#report)) — print activity for any date as Markdown or JSON for piping to scripts, dashboards, or other LLMs.
15
+ - **LLM digest** ([`agenthud summary`](#summary)) — synthesize a day or a date range into an engineering summary via the `claude` CLI, with caching so weekly digests are cheap to regenerate.
16
+
11
17
  ## Install
12
18
 
13
19
  Requires Node.js 20+.
@@ -18,9 +24,9 @@ npx agenthud
18
24
 
19
25
  Run this in a separate terminal while using Claude Code. Press `?` inside the TUI any time for in-app help.
20
26
 
21
- ## What it shows
27
+ ## Live monitor
22
28
 
23
- AgentHUD reads Claude Code's session files from `~/.claude/projects/` and displays them in a split view:
29
+ AgentHUD's TUI splits the screen into a project tree and an activity viewer:
24
30
 
25
31
  ```
26
32
  ┌─ Projects ───────────────────────────────────────────────┐
@@ -37,8 +43,7 @@ AgentHUD reads Claude Code's session files from `~/.claude/projects/` and displa
37
43
  │ [10:23] ~ Edit src/ui/App.tsx │
38
44
  │ [10:23] $ Bash npm test │
39
45
  │ [10:23] < Response Tests passed successfully │
40
- │ [10:25] abc1234 feat: fix auth callback
41
- │ › │
46
+ │ [10:25] Edit src/auth/oauth.ts ← bold + spinner = live
42
47
  └──────────────────────────────────────────────────────────┘
43
48
  ```
44
49
 
@@ -53,11 +58,11 @@ AgentHUD reads Claude Code's session files from `~/.claude/projects/` and displa
53
58
 
54
59
  **Activity viewer (bottom pane)**
55
60
  - Real-time feed for the selected session: file reads, edits, bash, responses, thinking, git commits. Newest at the bottom, like `tail -f`.
56
- - A `›` slides left right along the bottom row while the viewer is in LIVE mode visible proof the feed is alive. Hidden when paused (scrolled into history) or when the session has no activity yet.
61
+ - The **newest visible row is rendered "alive"** while in LIVE mode: its icon is replaced with a spinning glyph and the text turns bold. When a new activity arrives, the spinner moves to the new bottom row. Hidden when paused or empty.
57
62
  - Press `f` to cycle through filter presets (configurable).
58
63
  - Press `↵` on any row to open a scrollable detail view; on a commit row this shows `git show --stat --patch`.
59
64
 
60
- ## Session status
65
+ ### Session status
61
66
 
62
67
  Each session row carries a colored badge derived from when its JSONL file was last touched:
63
68
 
@@ -70,7 +75,7 @@ Each session row carries a colored badge derived from when its JSONL file was la
70
75
 
71
76
  Sub-agents use the same scheme. Projects inherit the hottest status of their sessions; a project is treated as "cold" only when all its sessions are cold.
72
77
 
73
- ## Activity types
78
+ ### Activity types
74
79
 
75
80
  | Icon | Type | Description |
76
81
  |------|------|-------------|
@@ -85,11 +90,11 @@ Sub-agents use the same scheme. Projects inherit the hottest status of their ses
85
90
  | `…` | Thinking | Claude's thinking (requires `showThinkingSummaries: true`) |
86
91
  | `◆` | Commit | Git commit in the project (when `--with-git` or in viewer) |
87
92
 
88
- ## Keyboard shortcuts
93
+ ### Keyboard shortcuts
89
94
 
90
95
  Full reference is also available inside the app — press `?`.
91
96
 
92
- ### Project tree focus
97
+ #### Project tree focus
93
98
 
94
99
  | Key | Action |
95
100
  |-----|--------|
@@ -100,11 +105,14 @@ Full reference is also available inside the app — press `?`.
100
105
  | `Tab` | Switch focus to activity viewer |
101
106
  | `PgUp` / `Ctrl+B` | Page up |
102
107
  | `PgDn` / `Ctrl+F` | Page down |
108
+ | `t` | Track — auto-follow the newest live sub-agent (any nav key turns it off) |
103
109
  | `r` | Refresh now |
104
110
  | `?` | Help |
105
111
  | `q` | Quit |
106
112
 
107
- ### Activity viewer focus
113
+ When tracking is on, the tree panel's title shows `[LIVE ⠧]` and the status bar replaces `t: track` with `TRK ●`. Any explicit selection-changing key (`↑/k`, `↓/j`, `PgUp/PgDn`, `↵`, `h`, or `t` again) turns tracking off.
114
+
115
+ #### Activity viewer focus
108
116
 
109
117
  | Key | Action |
110
118
  |-----|--------|
@@ -122,7 +130,7 @@ Full reference is also available inside the app — press `?`.
122
130
  | `?` | Help |
123
131
  | `q` | Quit |
124
132
 
125
- ### Detail view
133
+ #### Detail view
126
134
 
127
135
  | Key | Action |
128
136
  |-----|--------|
@@ -134,7 +142,7 @@ Detail view colors the content based on activity type:
134
142
  - **Git commit detail** (`git show --stat --patch`): added lines green (`+`), removed lines red (`-`), hunk headers cyan (`@@ ... @@`), `commit/Author/Date/diff` metadata dimmed.
135
143
  - **Response / thinking / prompt**: text inside triple-backtick code fences renders in cyan so the boundary between prose and code is obvious. No language-specific syntax highlighting — just code-vs-prose separation.
136
144
 
137
- ## Behavior
145
+ ### Behavior
138
146
 
139
147
  - **Alternate screen buffer.** Watch mode uses the alt-screen (like `vim`, `htop`, `btop`), so quitting (`q`) restores the pre-launch shell completely. No TUI residue, no "is it still running?" confusion.
140
148
  - **Minimum terminal size.** 80 cols × 20 rows. Smaller terminals show a one-line hint and redraw automatically when you resize.
@@ -194,6 +202,10 @@ agenthud summary --prompt "Only commits" # override prompt
194
202
  agenthud summary --last 7d # last 7 days, ending today
195
203
  agenthud summary --from 2026-05-10 --to 2026-05-16 # explicit range
196
204
  agenthud summary --last 7d -y # skip per-day confirmations
205
+
206
+ # Cheaper model — summarization doesn't need Opus-tier reasoning
207
+ agenthud summary --date today --model sonnet # ~40% cheaper than Opus
208
+ agenthud summary --last 7d --model haiku # ~80% cheaper, 200K context
197
209
  ```
198
210
 
199
211
  **Daily summaries** are saved to `~/.agenthud/summaries/YYYY-MM-DD.md`. Past dates are cached and returned instantly; today is always regenerated (activity still growing).
@@ -206,6 +218,10 @@ Each missing daily prompts for confirmation just before generation, so you see c
206
218
 
207
219
  **`--date` formats:** `YYYY-MM-DD`, `today`, `yesterday`, or `-Nd` (N days ago).
208
220
 
221
+ **Model selection:** Summarization is a low-reasoning task (structured input → structured markdown) — Sonnet or Haiku usually beats Opus on cost-per-summary with no quality loss. Pass `--model sonnet`, `--model haiku`, or a full model id (`--model claude-sonnet-4-6`). With no flag, `claude` uses its default model.
222
+
223
+ **Cost warning:** If the day's activity log is large (~300K tokens or more), AgentHUD prints a warning before sending and asks for one more confirmation in interactive mode. `-y` skips the prompt but still prints the warning.
224
+
209
225
  **Requires:** [`@anthropic-ai/claude-code`](https://www.npmjs.com/package/@anthropic-ai/claude-code) installed and authenticated.
210
226
 
211
227
  ## Configuration
package/dist/index.js CHANGED
@@ -15,4 +15,4 @@ Error: Node.js ${MIN_NODE_VERSION}+ is required (current: ${process.version})
15
15
  process.exit(1);
16
16
  }
17
17
  if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
18
- import("./main-S27FZ2BJ.js");
18
+ import("./main-HPL3AG6B.js");
@@ -53,6 +53,7 @@ var KNOWN_SUMMARY_FLAGS = /* @__PURE__ */ new Set([
53
53
  "--to",
54
54
  "--prompt",
55
55
  "--force",
56
+ "--model",
56
57
  "-y",
57
58
  "--yes"
58
59
  ]);
@@ -89,6 +90,7 @@ Commands:
89
90
  --to YYYY-MM-DD Date range: end date (use with --from)
90
91
  --prompt TEXT Override prompt for this run (daily only)
91
92
  --force Regenerate even if cached
93
+ --model NAME Pass --model to claude (e.g. "sonnet", "haiku", or a full model ID)
92
94
  -y, --yes Skip confirmation prompts for new daily summaries
93
95
 
94
96
  Environment:
@@ -172,10 +174,18 @@ function parseArgs(args) {
172
174
  const includeIdx = rest.indexOf("--include");
173
175
  if (includeIdx !== -1) {
174
176
  const includeStr = rest[includeIdx + 1];
175
- if (includeStr === "all") {
177
+ if (!includeStr) {
178
+ reportError = "Invalid --include: missing value.";
179
+ } else if (includeStr === "all") {
176
180
  reportInclude = ALL_TYPES;
177
- } else if (includeStr) {
178
- reportInclude = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
181
+ } else {
182
+ const tokens = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
183
+ const unknown = tokens.filter((t) => !ALL_TYPES.includes(t));
184
+ if (unknown.length > 0) {
185
+ reportError = `Unknown --include type${unknown.length > 1 ? "s" : ""}: ${unknown.map((u) => `"${u}"`).join(", ")}. Valid types: ${ALL_TYPES.join(", ")} (or "all").`;
186
+ } else {
187
+ reportInclude = tokens;
188
+ }
179
189
  }
180
190
  }
181
191
  let reportFormat = "markdown";
@@ -220,13 +230,15 @@ function parseArgs(args) {
220
230
  let summaryPrompt;
221
231
  let summaryForce = false;
222
232
  let summaryAssumeYes = false;
233
+ let summaryModel;
223
234
  let summaryError;
224
235
  const FLAGS_WITH_VALUE = /* @__PURE__ */ new Set([
225
236
  "--date",
226
237
  "--last",
227
238
  "--from",
228
239
  "--to",
229
- "--prompt"
240
+ "--prompt",
241
+ "--model"
230
242
  ]);
231
243
  for (let i = 0; i < rest.length; i++) {
232
244
  const arg = rest[i];
@@ -318,6 +330,15 @@ function parseArgs(args) {
318
330
  summaryPrompt = val;
319
331
  }
320
332
  }
333
+ const modelIdx = rest.indexOf("--model");
334
+ if (modelIdx !== -1) {
335
+ const val = rest[modelIdx + 1];
336
+ if (!val) {
337
+ summaryError = "Invalid --model: missing value (e.g. --model sonnet).";
338
+ } else {
339
+ summaryModel = val;
340
+ }
341
+ }
321
342
  if (rest.includes("--force")) summaryForce = true;
322
343
  if (rest.includes("-y") || rest.includes("--yes")) summaryAssumeYes = true;
323
344
  return {
@@ -328,6 +349,7 @@ function parseArgs(args) {
328
349
  summaryPrompt,
329
350
  summaryForce,
330
351
  summaryAssumeYes,
352
+ summaryModel,
331
353
  summaryError
332
354
  };
333
355
  }
@@ -663,20 +685,11 @@ function parseGitCommits(projectPath, startDate, endDate) {
663
685
  // src/data/sessionHistory.ts
664
686
  import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
665
687
 
666
- // src/data/activityParser.ts
688
+ // src/data/toolDetails.ts
667
689
  import { basename } from "path";
668
690
  function stripAnsi(text) {
669
691
  return text.replace(/\x1b\[[0-9;]*m/g, "");
670
692
  }
671
- function parseModelName(modelId) {
672
- const opusMatch = modelId.match(/claude-opus-(\d+)-(\d+)/);
673
- if (opusMatch) return `opus-${opusMatch[1]}.${opusMatch[2]}`;
674
- const sonnetMatch = modelId.match(/claude-sonnet-(\d+)/);
675
- if (sonnetMatch) return `sonnet-${sonnetMatch[1]}`;
676
- const haikuMatch = modelId.match(/claude-(\d+)-(\d+)-haiku/);
677
- if (haikuMatch) return `haiku-${haikuMatch[1]}.${haikuMatch[2]}`;
678
- return modelId.replace(/-\d{8}$/, "");
679
- }
680
693
  function getToolDetail(_toolName, input) {
681
694
  if (!input) return "";
682
695
  if (input.command) return stripAnsi(input.command);
@@ -686,11 +699,128 @@ function getToolDetail(_toolName, input) {
686
699
  if (input.description) return stripAnsi(input.description);
687
700
  return "";
688
701
  }
702
+ function rangeStr(start, lines) {
703
+ return `L${start}-${start + Math.max(lines, 1) - 1}`;
704
+ }
705
+ function patchSpan(hunks) {
706
+ if (hunks.length === 0) return null;
707
+ const start = Math.min(...hunks.map((h) => h.newStart));
708
+ const end = Math.max(
709
+ ...hunks.map((h) => h.newStart + Math.max(h.newLines, 1) - 1)
710
+ );
711
+ return `L${start}-${end}`;
712
+ }
713
+ function countChanges(hunks) {
714
+ let add = 0;
715
+ let del = 0;
716
+ for (const h of hunks) {
717
+ for (const line of h.lines ?? []) {
718
+ if (line.startsWith("+")) add++;
719
+ else if (line.startsWith("-")) del++;
720
+ }
721
+ }
722
+ const parts = [];
723
+ if (add > 0) parts.push(`+${add}`);
724
+ if (del > 0) parts.push(`-${del}`);
725
+ return parts.join(" ");
726
+ }
727
+ function joinParts(...parts) {
728
+ return parts.filter((p) => !!p).join(" ");
729
+ }
730
+ function summarizeToolDetail(name, input, result) {
731
+ const file = input?.file_path ? basename(input.file_path) : "";
732
+ if (name === "Edit" || name === "Write") {
733
+ const hunks = result?.structuredPatch;
734
+ if (hunks && hunks.length > 0) {
735
+ return joinParts(file, patchSpan(hunks), countChanges(hunks));
736
+ }
737
+ if (name === "Write") {
738
+ const content = result?.content ?? input?.content;
739
+ if (content) {
740
+ const n = content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
741
+ return joinParts(file, rangeStr(1, n), `+${n}`);
742
+ }
743
+ }
744
+ return file;
745
+ }
746
+ if (name === "Read") {
747
+ const f = result?.file;
748
+ if (typeof f?.startLine === "number" && typeof f?.numLines === "number") {
749
+ return joinParts(file, rangeStr(f.startLine, f.numLines));
750
+ }
751
+ if (typeof input?.offset === "number" && typeof input?.limit === "number") {
752
+ return joinParts(file, rangeStr(input.offset, input.limit));
753
+ }
754
+ return file;
755
+ }
756
+ if (name === "TaskUpdate") {
757
+ const id = input?.taskId ?? result?.taskId;
758
+ const idStr = id ? `#${id}` : "";
759
+ const sc = result?.statusChange;
760
+ if (sc?.from && sc?.to) return joinParts(idStr, `${sc.from}\u2192${sc.to}`);
761
+ if (result?.updatedFields?.length) {
762
+ return joinParts(idStr, result.updatedFields.join(", "));
763
+ }
764
+ if (input?.status) return joinParts(idStr, input.status);
765
+ return idStr;
766
+ }
767
+ if (name === "TaskCreate") {
768
+ return input?.subject ?? "";
769
+ }
770
+ return getToolDetail(name, input);
771
+ }
772
+ function buildToolDetailBody(name, input, result) {
773
+ if (name === "Write") {
774
+ const content = result?.content ?? input?.content;
775
+ if (content) return { text: content, kind: "code" };
776
+ }
777
+ if (name === "Edit" || name === "Write") {
778
+ const hunks = result?.structuredPatch;
779
+ if (hunks && hunks.length > 0) {
780
+ const text = hunks.map(
781
+ (h) => `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@
782
+ ${(h.lines ?? []).join("\n")}`
783
+ ).join("\n");
784
+ return { text, kind: "diff" };
785
+ }
786
+ }
787
+ return null;
788
+ }
789
+
790
+ // src/data/activityParser.ts
791
+ function parseModelName(modelId) {
792
+ const opusMatch = modelId.match(/claude-opus-(\d+)-(\d+)/);
793
+ if (opusMatch) return `opus-${opusMatch[1]}.${opusMatch[2]}`;
794
+ const sonnetMatch = modelId.match(/claude-sonnet-(\d+)/);
795
+ if (sonnetMatch) return `sonnet-${sonnetMatch[1]}`;
796
+ const haikuMatch = modelId.match(/claude-(\d+)-(\d+)-haiku/);
797
+ if (haikuMatch) return `haiku-${haikuMatch[1]}.${haikuMatch[2]}`;
798
+ return modelId.replace(/-\d{8}$/, "");
799
+ }
689
800
  function parseActivitiesFromLines(lines) {
690
801
  const activities = [];
691
802
  let tokenCount = 0;
692
803
  let modelName = null;
693
804
  let sessionStartTime = null;
805
+ const resultsById = /* @__PURE__ */ new Map();
806
+ for (const line of lines) {
807
+ let entry;
808
+ try {
809
+ entry = JSON.parse(line);
810
+ } catch {
811
+ continue;
812
+ }
813
+ if (entry.type !== "user") continue;
814
+ const tur = entry.toolUseResult;
815
+ if (!tur || typeof tur !== "object") continue;
816
+ const content = entry.message?.content;
817
+ if (!Array.isArray(content)) continue;
818
+ for (const b of content) {
819
+ if (b?.type === "tool_result" && typeof b.tool_use_id === "string") {
820
+ resultsById.set(b.tool_use_id, tur);
821
+ }
822
+ }
823
+ }
694
824
  for (const line of lines) {
695
825
  let entry;
696
826
  try {
@@ -745,19 +875,26 @@ function parseActivitiesFromLines(lines) {
745
875
  } else if (block.type === "tool_use" && block.name) {
746
876
  if (block.name === "TodoWrite") continue;
747
877
  const icon = ICONS[block.name] ?? ICONS.Default;
748
- const detail = getToolDetail(block.name, block.input);
878
+ const result = block.id ? resultsById.get(block.id) : void 0;
879
+ const detail = summarizeToolDetail(block.name, block.input, result);
880
+ const body = buildToolDetailBody(block.name, block.input, result);
749
881
  const last = activities[activities.length - 1];
750
882
  if (last && last.type === "tool" && last.label === block.name && last.detail === detail) {
751
883
  last.count = (last.count ?? 1) + 1;
752
884
  last.timestamp = timestamp;
753
885
  } else {
754
- activities.push({
886
+ const entry2 = {
755
887
  timestamp,
756
888
  type: "tool",
757
889
  icon,
758
890
  label: block.name,
759
891
  detail
760
- });
892
+ };
893
+ if (body) {
894
+ entry2.detailBody = body.text;
895
+ entry2.detailKind = body.kind;
896
+ }
897
+ activities.push(entry2);
761
898
  }
762
899
  } else if (block.type === "text" && block.text && block.text.length > 10) {
763
900
  activities.push({
@@ -1004,6 +1141,33 @@ function getDisplayWidth(s) {
1004
1141
  return w;
1005
1142
  }
1006
1143
 
1144
+ // src/data/sessionLiveness.ts
1145
+ function detectLiveState(tailLines, mtimeMs, now) {
1146
+ if (now - mtimeMs > THIRTY_MINUTES_MS) return null;
1147
+ for (let i = tailLines.length - 1; i >= 0; i--) {
1148
+ const line = tailLines[i];
1149
+ if (!line || !line.trim()) continue;
1150
+ let entry;
1151
+ try {
1152
+ entry = JSON.parse(line);
1153
+ } catch {
1154
+ continue;
1155
+ }
1156
+ if (entry.type === "assistant") {
1157
+ const content = entry.message?.content;
1158
+ const blocks = Array.isArray(content) ? content : [];
1159
+ const toolUses = blocks.filter((b) => b && b.type === "tool_use");
1160
+ if (toolUses.length === 0) return "waiting";
1161
+ if (toolUses.some((b) => b.name === "AskUserQuestion")) return "waiting";
1162
+ return "working";
1163
+ }
1164
+ if (entry.type === "user") {
1165
+ return "working";
1166
+ }
1167
+ }
1168
+ return null;
1169
+ }
1170
+
1007
1171
  // src/data/sessions.ts
1008
1172
  function getProjectsDir() {
1009
1173
  return process.env.CLAUDE_PROJECTS_DIR ?? join4(homedir2(), ".claude", "projects");
@@ -1057,23 +1221,26 @@ function readSubAgentInfo(filePath) {
1057
1221
  return { agentId: null, taskDescription: null };
1058
1222
  }
1059
1223
  }
1060
- function readModelName(filePath) {
1061
- if (!existsSync3(filePath)) return null;
1224
+ function readSessionTail(filePath, mtimeMs, now) {
1225
+ if (!existsSync3(filePath)) return { modelName: null, liveState: null };
1062
1226
  try {
1063
1227
  const content = readFileSync4(filePath, "utf-8");
1064
- const lines = content.trim().split("\n").filter(Boolean);
1065
- for (const line of lines.slice(-50).reverse()) {
1228
+ const tail = content.trim().split("\n").filter(Boolean).slice(-50);
1229
+ let modelName = null;
1230
+ for (const line of [...tail].reverse()) {
1066
1231
  try {
1067
1232
  const entry = JSON.parse(line);
1068
1233
  if (entry.type === "assistant" && entry.message?.model) {
1069
- return parseModelName(entry.message.model);
1234
+ modelName = parseModelName(entry.message.model);
1235
+ break;
1070
1236
  }
1071
1237
  } catch {
1072
1238
  }
1073
1239
  }
1240
+ return { modelName, liveState: detectLiveState(tail, mtimeMs, now) };
1074
1241
  } catch {
1242
+ return { modelName: null, liveState: null };
1075
1243
  }
1076
- return null;
1077
1244
  }
1078
1245
  var SYSTEM_PREFIXES = [
1079
1246
  "<command-name>",
@@ -1157,6 +1324,11 @@ function buildSubAgents(parentId, projectDir, config, projectName) {
1157
1324
  try {
1158
1325
  const stat = statSync2(filePath);
1159
1326
  const { agentId, taskDescription } = readSubAgentInfo(filePath);
1327
+ const { modelName, liveState } = readSessionTail(
1328
+ filePath,
1329
+ stat.mtimeMs,
1330
+ Date.now()
1331
+ );
1160
1332
  return {
1161
1333
  id,
1162
1334
  hideKey,
@@ -1165,12 +1337,13 @@ function buildSubAgents(parentId, projectDir, config, projectName) {
1165
1337
  projectName: "",
1166
1338
  lastModifiedMs: stat.mtimeMs,
1167
1339
  status: getSessionStatus(stat.mtimeMs),
1168
- modelName: readModelName(filePath),
1340
+ modelName,
1169
1341
  subAgents: [],
1170
1342
  agentId: agentId ?? void 0,
1171
1343
  taskDescription: taskDescription ?? void 0,
1172
1344
  nonInteractive: false,
1173
- firstUserPrompt: null
1345
+ firstUserPrompt: null,
1346
+ liveState
1174
1347
  };
1175
1348
  } catch {
1176
1349
  return null;
@@ -1226,6 +1399,12 @@ function discoverSessions(config) {
1226
1399
  try {
1227
1400
  const stat = statSync2(filePath);
1228
1401
  const subAgents = buildSubAgents(id, projectDir, config, projectName);
1402
+ const nonInteractive = readEntrypoint(filePath) === "sdk-cli";
1403
+ const { modelName, liveState } = readSessionTail(
1404
+ filePath,
1405
+ stat.mtimeMs,
1406
+ Date.now()
1407
+ );
1229
1408
  allSessions.push({
1230
1409
  id,
1231
1410
  hideKey,
@@ -1234,10 +1413,11 @@ function discoverSessions(config) {
1234
1413
  projectName,
1235
1414
  lastModifiedMs: stat.mtimeMs,
1236
1415
  status: getSessionStatus(stat.mtimeMs),
1237
- modelName: readModelName(filePath),
1416
+ modelName,
1238
1417
  subAgents,
1239
- nonInteractive: readEntrypoint(filePath) === "sdk-cli",
1240
- firstUserPrompt: readFirstUserPrompt(filePath)
1418
+ nonInteractive,
1419
+ firstUserPrompt: readFirstUserPrompt(filePath),
1420
+ liveState: nonInteractive ? null : liveState
1241
1421
  });
1242
1422
  } catch {
1243
1423
  }
@@ -1391,16 +1571,18 @@ function formatUsage(u) {
1391
1571
  }
1392
1572
  function spawnClaude(opts) {
1393
1573
  return new Promise((resolve2) => {
1574
+ const args = [
1575
+ "-p",
1576
+ "--no-session-persistence",
1577
+ "--output-format",
1578
+ "stream-json",
1579
+ "--verbose"
1580
+ ];
1581
+ if (opts.model) args.push("--model", opts.model);
1582
+ args.push(opts.prompt);
1394
1583
  const proc = spawn(
1395
1584
  "claude",
1396
- [
1397
- "-p",
1398
- "--no-session-persistence",
1399
- "--output-format",
1400
- "stream-json",
1401
- "--verbose",
1402
- opts.prompt
1403
- ],
1585
+ args,
1404
1586
  {
1405
1587
  stdio: ["pipe", "pipe", "pipe"],
1406
1588
  cwd: agenthudHomeDir()
@@ -1513,6 +1695,7 @@ function spawnClaude(opts) {
1513
1695
  proc.stdin.end(opts.stdin);
1514
1696
  });
1515
1697
  }
1698
+ var REPORT_TOKEN_WARN_THRESHOLD = 3e5;
1516
1699
  async function generateDailySummary(opts) {
1517
1700
  ensureUserPromptFile("daily");
1518
1701
  const isToday = isSameLocalDay2(opts.date, opts.today);
@@ -1556,6 +1739,8 @@ async function generateDailySummary(opts) {
1556
1739
  detailLimit: 0,
1557
1740
  withGit: true
1558
1741
  });
1742
+ const reportBytes = Buffer.byteLength(reportMarkdown, "utf-8");
1743
+ const estimatedTokens = Math.ceil(reportBytes / 4);
1559
1744
  if (opts.announce) {
1560
1745
  const reportLines = reportMarkdown.split("\n");
1561
1746
  const sessionCount = reportLines.filter((l) => l.startsWith("## ")).length;
@@ -1565,11 +1750,9 @@ async function generateDailySummary(opts) {
1565
1750
  const commitCount = reportLines.filter(
1566
1751
  (l) => /^\[\d{2}:\d{2}\] ◆/.test(l)
1567
1752
  ).length;
1568
- const sizeKb = (Buffer.byteLength(reportMarkdown, "utf-8") / 1024).toFixed(
1569
- 1
1570
- );
1753
+ const sizeKb = (reportBytes / 1024).toFixed(1);
1571
1754
  process.stderr.write(
1572
- `agenthud: input: ${sessionCount} sessions, ${activityCount} activities, ${commitCount} commits (${reportLines.length} lines, ${sizeKb}KB)
1755
+ `agenthud: input: ${sessionCount} sessions, ${activityCount} activities, ${commitCount} commits (${reportLines.length} lines, ${sizeKb}KB \u2248 ${estimatedTokens.toLocaleString()} tokens)
1573
1756
  `
1574
1757
  );
1575
1758
  }
@@ -1585,6 +1768,29 @@ async function generateDailySummary(opts) {
1585
1768
  };
1586
1769
  }
1587
1770
  }
1771
+ if (estimatedTokens > REPORT_TOKEN_WARN_THRESHOLD) {
1772
+ const sizeMb = (reportBytes / (1024 * 1024)).toFixed(1);
1773
+ process.stderr.write(
1774
+ `agenthud: \u26A0 report is large (~${estimatedTokens.toLocaleString()} tokens, ${sizeMb}MB). Cost will be high; very long reports may exceed context.
1775
+ `
1776
+ );
1777
+ if (!opts.assumeYes) {
1778
+ const proceed = await ask("Send anyway? [Y/n] ", true);
1779
+ if (!proceed) {
1780
+ process.stderr.write(
1781
+ `agenthud: ${dateLabel} \u2014 aborted (report too large).
1782
+ `
1783
+ );
1784
+ return {
1785
+ code: 0,
1786
+ markdown: "",
1787
+ fromCache: false,
1788
+ skipped: true,
1789
+ usage: null
1790
+ };
1791
+ }
1792
+ }
1793
+ }
1588
1794
  if (opts.announce) {
1589
1795
  process.stderr.write(
1590
1796
  `agenthud: sending to claude (this may take a minute)...
@@ -1597,7 +1803,8 @@ async function generateDailySummary(opts) {
1597
1803
  prompt,
1598
1804
  stdin: reportMarkdown,
1599
1805
  cachePath: cached,
1600
- streamToStdout: opts.streamToStdout
1806
+ streamToStdout: opts.streamToStdout,
1807
+ model: opts.model
1601
1808
  });
1602
1809
  if (opts.announce && result.code === 0) {
1603
1810
  process.stderr.write("\n");
@@ -1623,7 +1830,8 @@ async function runSummary(options2) {
1623
1830
  force: options2.force,
1624
1831
  promptOverride: options2.prompt,
1625
1832
  streamToStdout: true,
1626
- announce: true
1833
+ announce: true,
1834
+ model: options2.model
1627
1835
  });
1628
1836
  return res.code;
1629
1837
  }
@@ -1686,7 +1894,9 @@ agenthud: --- ${label} ---
1686
1894
  force: false,
1687
1895
  streamToStdout: false,
1688
1896
  announce: true,
1689
- confirmBeforeSpawn: confirmer
1897
+ confirmBeforeSpawn: confirmer,
1898
+ assumeYes: options2.assumeYes,
1899
+ model: options2.model
1690
1900
  });
1691
1901
  if (res.skipped) {
1692
1902
  process.stderr.write(`agenthud: ${label} \u2014 skipped by user.
@@ -1731,7 +1941,8 @@ agenthud: combining ${dailyMarkdowns.length} daily summaries into range summary.
1731
1941
  prompt: metaPrompt,
1732
1942
  stdin: metaInput,
1733
1943
  cachePath: rangeCache,
1734
- streamToStdout: true
1944
+ streamToStdout: true,
1945
+ model: options2.model
1735
1946
  });
1736
1947
  if (metaResult.code !== 0) {
1737
1948
  return metaResult.code;
@@ -1753,7 +1964,33 @@ import { useCallback, useEffect as useEffect3, useMemo, useRef, useState as useS
1753
1964
 
1754
1965
  // src/ui/ActivityViewerPanel.tsx
1755
1966
  import { Box, Text } from "ink";
1756
- import { jsx, jsxs } from "react/jsx-runtime";
1967
+ import { memo } from "react";
1968
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1969
+ function brighten(color) {
1970
+ switch (color) {
1971
+ case void 0:
1972
+ case "gray":
1973
+ return "white";
1974
+ case "white":
1975
+ return "whiteBright";
1976
+ case "green":
1977
+ return "greenBright";
1978
+ case "yellow":
1979
+ return "yellowBright";
1980
+ case "magenta":
1981
+ return "magentaBright";
1982
+ case "cyan":
1983
+ return "cyanBright";
1984
+ case "red":
1985
+ return "redBright";
1986
+ case "blue":
1987
+ return "blueBright";
1988
+ case "black":
1989
+ return "blackBright";
1990
+ default:
1991
+ return color;
1992
+ }
1993
+ }
1757
1994
  function getActivityStyle(activity) {
1758
1995
  if (activity.type === "user") {
1759
1996
  return { color: "white", dimColor: false };
@@ -1802,6 +2039,76 @@ function truncateDetail2(detail, maxWidth) {
1802
2039
  }
1803
2040
  return `${truncated}\u2026`;
1804
2041
  }
2042
+ var ActivityRow = memo(function ActivityRow2({
2043
+ activity,
2044
+ timestamp,
2045
+ width,
2046
+ contentWidth,
2047
+ isCursor,
2048
+ isLiveRow,
2049
+ liveSpinnerFrame,
2050
+ liveTick
2051
+ }) {
2052
+ const style = getActivityStyle(activity);
2053
+ const timestampWidth = timestamp.length;
2054
+ const icon = isLiveRow && liveSpinnerFrame ? liveSpinnerFrame : activity.icon;
2055
+ const iconWidth = getDisplayWidth(icon);
2056
+ const label = activity.label;
2057
+ const detail = activity.detail;
2058
+ const count = activity.count;
2059
+ const countSuffix = count && count > 1 ? ` (\xD7${count})` : "";
2060
+ const countSuffixWidth = countSuffix.length;
2061
+ const prefixWidth = 2 + timestampWidth + iconWidth + 1;
2062
+ const labelPart = detail ? `${label}: ` : label;
2063
+ const labelWidth = labelPart.length;
2064
+ const detailMaxWidth = width - 2 - timestampWidth - iconWidth - 1 - labelWidth - countSuffixWidth - 1;
2065
+ let labelContent;
2066
+ if (detail) {
2067
+ const truncated = truncateDetail2(detail, Math.max(0, detailMaxWidth));
2068
+ labelContent = `${labelPart}${truncated}${countSuffix}`;
2069
+ } else {
2070
+ labelContent = label + countSuffix;
2071
+ }
2072
+ const usedWidth = 1 + 1 + timestampWidth + iconWidth + 1 + getDisplayWidth(labelContent) + 1;
2073
+ const padding = Math.max(0, width - usedWidth);
2074
+ const SWEEP_WIDTH = 10;
2075
+ let labelNode = labelContent;
2076
+ if (isLiveRow && !isCursor && liveTick != null && labelContent.length > 0) {
2077
+ const period = labelContent.length + SWEEP_WIDTH;
2078
+ const offset = liveTick % period - SWEEP_WIDTH;
2079
+ const litStart = Math.max(0, offset);
2080
+ const litEnd = Math.min(labelContent.length, offset + SWEEP_WIDTH);
2081
+ if (litEnd > litStart) {
2082
+ const pre = labelContent.slice(0, litStart);
2083
+ const lit = labelContent.slice(litStart, litEnd);
2084
+ const post = labelContent.slice(litEnd);
2085
+ labelNode = /* @__PURE__ */ jsxs(Fragment, { children: [
2086
+ pre,
2087
+ /* @__PURE__ */ jsx(Text, { color: brighten(style.color), bold: true, children: lit }),
2088
+ post
2089
+ ] });
2090
+ }
2091
+ }
2092
+ return /* @__PURE__ */ jsxs(Text, { children: [
2093
+ BOX.v,
2094
+ " ",
2095
+ /* @__PURE__ */ jsxs(Text, { backgroundColor: isCursor ? "blue" : void 0, children: [
2096
+ /* @__PURE__ */ jsx(Text, { dimColor: !isCursor && !isLiveRow, children: timestamp }),
2097
+ /* @__PURE__ */ jsx(Text, { color: "cyan", bold: isLiveRow, children: icon }),
2098
+ " ",
2099
+ /* @__PURE__ */ jsx(
2100
+ Text,
2101
+ {
2102
+ color: isCursor ? void 0 : style.color,
2103
+ dimColor: !isCursor && !isLiveRow && style.dimColor,
2104
+ children: labelNode
2105
+ }
2106
+ ),
2107
+ " ".repeat(padding)
2108
+ ] }),
2109
+ BOX.v
2110
+ ] });
2111
+ });
1805
2112
  function ActivityViewerPanel({
1806
2113
  activities,
1807
2114
  sessionName,
@@ -1809,8 +2116,8 @@ function ActivityViewerPanel({
1809
2116
  isLive,
1810
2117
  newCount,
1811
2118
  visibleRows,
1812
- trailingBlankRows = 0,
1813
- liveIndicatorPosition = null,
2119
+ liveSpinnerFrame = null,
2120
+ liveTick = null,
1814
2121
  width,
1815
2122
  cursorLine,
1816
2123
  hasFocus,
@@ -1854,57 +2161,28 @@ function ActivityViewerPanel({
1854
2161
  } else {
1855
2162
  const effectiveCursor = Math.min(cursorLine, visibleActivities.length - 1);
1856
2163
  const cursorIndexInSlice = visibleActivities.length - 1 - effectiveCursor;
2164
+ const liveRowIndex = visibleActivities.length - 1;
2165
+ const liveTreatment = isLive && !!liveSpinnerFrame;
1857
2166
  for (let i = 0; i < visibleActivities.length; i++) {
1858
2167
  const activity = visibleActivities[i];
1859
- const style = getActivityStyle(activity);
1860
2168
  const isCursor = hasFocus && i === cursorIndexInSlice;
1861
- const time = formatActivityTime(activity.timestamp, now);
1862
- const timestamp = `[${time}] `;
1863
- const timestampWidth = timestamp.length;
1864
- const icon = activity.icon;
1865
- const iconWidth = getDisplayWidth(icon);
1866
- const label = activity.label;
1867
- const detail = activity.detail;
1868
- const count = activity.count;
1869
- const countSuffix = count && count > 1 ? ` (\xD7${count})` : "";
1870
- const countSuffixWidth = countSuffix.length;
1871
- const prefixWidth = 2 + timestampWidth + iconWidth + 1;
1872
- const labelPart = detail ? `${label}: ` : label;
1873
- const labelWidth = labelPart.length;
1874
- const _availableForDetail = contentWidth - prefixWidth - labelWidth - countSuffixWidth + 1;
1875
- const detailMaxWidth = width - 2 - timestampWidth - iconWidth - 1 - labelWidth - countSuffixWidth - 1;
1876
- let labelContent;
1877
- let _displayWidth;
1878
- if (detail) {
1879
- const truncated = truncateDetail2(detail, Math.max(0, detailMaxWidth));
1880
- labelContent = `${labelPart}${truncated}${countSuffix}`;
1881
- _displayWidth = prefixWidth - 1 + labelWidth + getDisplayWidth(truncated) + countSuffixWidth;
1882
- } else {
1883
- labelContent = label + countSuffix;
1884
- _displayWidth = prefixWidth - 1 + label.length + countSuffixWidth;
1885
- }
1886
- const usedWidth = 1 + 1 + timestampWidth + iconWidth + 1 + getDisplayWidth(labelContent) + 1;
1887
- const padding = Math.max(0, width - usedWidth);
2169
+ const isLiveRow = liveTreatment && i === liveRowIndex;
2170
+ const timestamp = `[${formatActivityTime(activity.timestamp, now)}] `;
1888
2171
  lines.push(
1889
- /* @__PURE__ */ jsxs(Text, { children: [
1890
- BOX.v,
1891
- " ",
1892
- /* @__PURE__ */ jsxs(Text, { backgroundColor: isCursor ? "blue" : void 0, children: [
1893
- /* @__PURE__ */ jsx(Text, { dimColor: !isCursor, children: timestamp }),
1894
- /* @__PURE__ */ jsx(Text, { color: "cyan", children: icon }),
1895
- " ",
1896
- /* @__PURE__ */ jsx(
1897
- Text,
1898
- {
1899
- color: isCursor ? void 0 : style.color,
1900
- dimColor: !isCursor && style.dimColor,
1901
- children: labelContent
1902
- }
1903
- ),
1904
- " ".repeat(padding)
1905
- ] }),
1906
- BOX.v
1907
- ] }, `activity-${i}`)
2172
+ /* @__PURE__ */ jsx(
2173
+ ActivityRow,
2174
+ {
2175
+ activity,
2176
+ timestamp,
2177
+ width,
2178
+ contentWidth,
2179
+ isCursor,
2180
+ isLiveRow,
2181
+ liveSpinnerFrame: isLiveRow ? liveSpinnerFrame ?? void 0 : void 0,
2182
+ liveTick: isLiveRow ? liveTick ?? void 0 : void 0
2183
+ },
2184
+ `activity-${i}`
2185
+ )
1908
2186
  );
1909
2187
  }
1910
2188
  }
@@ -1914,29 +2192,7 @@ function ActivityViewerPanel({
1914
2192
  for (let i = 0; i < padCount; i++) {
1915
2193
  padded.push(/* @__PURE__ */ jsx(Text, { children: emptyRow }, `pad-${i}`));
1916
2194
  }
1917
- const hasContent = visibleActivities.length > 0;
1918
- const trailing = [];
1919
- for (let i = 0; i < trailingBlankRows; i++) {
1920
- if (i === 0 && isLive && liveIndicatorPosition != null && hasContent) {
1921
- const pos = Math.max(0, liveIndicatorPosition);
1922
- const arrow = "\u203A";
1923
- const safePos = Math.min(pos, Math.max(0, contentWidth - 1));
1924
- const padAfter = Math.max(0, contentWidth - safePos - 1);
1925
- trailing.push(
1926
- /* @__PURE__ */ jsxs(Text, { children: [
1927
- BOX.v,
1928
- " ",
1929
- " ".repeat(safePos),
1930
- /* @__PURE__ */ jsx(Text, { color: "cyan", dimColor: true, children: arrow }),
1931
- " ".repeat(padAfter),
1932
- BOX.v
1933
- ] }, `trail-${i}`)
1934
- );
1935
- } else {
1936
- trailing.push(/* @__PURE__ */ jsx(Text, { children: emptyRow }, `trail-${i}`));
1937
- }
1938
- }
1939
- const finalLines = [...padded, ...lines, ...trailing];
2195
+ const finalLines = [...padded, ...lines];
1940
2196
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
1941
2197
  /* @__PURE__ */ jsx(Text, { color: isLive ? void 0 : "yellow", children: createTitleLine(sessionName, titleSuffix, width) }),
1942
2198
  finalLines,
@@ -2036,8 +2292,9 @@ function DetailViewPanel({
2036
2292
  }) {
2037
2293
  const innerWidth = getInnerWidth(width);
2038
2294
  const contentWidth = innerWidth - 1;
2039
- const classifier = activity.type === "commit" ? classifyDiffLines : classifyCodeFences;
2040
- const allLines = wrapClassified(activity.detail, contentWidth, classifier);
2295
+ const body = activity.detailBody ?? activity.detail;
2296
+ const classifier = activity.detailKind === "diff" ? classifyDiffLines : activity.detailKind === "code" ? classifyCodeFences : activity.type === "commit" ? classifyDiffLines : classifyCodeFences;
2297
+ const allLines = wrapClassified(body, contentWidth, classifier);
2041
2298
  const totalLines = allLines.length;
2042
2299
  const clampedOffset = Math.min(
2043
2300
  scrollOffset,
@@ -2102,6 +2359,7 @@ var SECTIONS = [
2102
2359
  ["PgDn / Ctrl+F", "Page down"],
2103
2360
  ["\u21B5", "Expand/collapse project, session, or summary"],
2104
2361
  ["h", "Hide selected (project/session/sub-agent)"],
2362
+ ["t", "Track: auto-follow the newest live sub-agent (any nav key turns it off)"],
2105
2363
  ["Tab", "Switch focus to activity viewer"],
2106
2364
  ["r", "Refresh now"]
2107
2365
  ]
@@ -2243,7 +2501,9 @@ function useHotkeys({
2243
2501
  onHelp,
2244
2502
  onHelpScroll,
2245
2503
  onHelpScrollToTop,
2246
- filterLabel
2504
+ onToggleTracking,
2505
+ filterLabel,
2506
+ trackingOn = false
2247
2507
  }) {
2248
2508
  const handleInput = (input, key) => {
2249
2509
  if (helpMode) {
@@ -2318,6 +2578,10 @@ function useHotkeys({
2318
2578
  onFilter();
2319
2579
  return;
2320
2580
  }
2581
+ if (input === "t" && !key.ctrl && onToggleTracking) {
2582
+ onToggleTracking();
2583
+ return;
2584
+ }
2321
2585
  if (key.pageUp) {
2322
2586
  onScrollPageUp();
2323
2587
  return;
@@ -2377,7 +2641,9 @@ function useHotkeys({
2377
2641
  }
2378
2642
  }
2379
2643
  };
2644
+ const trackingItems = trackingOn ? ["TRK \u25CF"] : ["t: track"];
2380
2645
  const statusBarItems = helpMode ? ["\u2191\u2193/jk: scroll", "PgDn/Space: page", "\u21B5/Esc/q/?: close"] : detailMode ? ["\u2191\u2193/jk: scroll", "\u21B5/Esc: close", "?: help"] : focus === "tree" ? [
2646
+ ...trackingItems,
2381
2647
  "Tab: viewer",
2382
2648
  "\u2191\u2193/jk: select",
2383
2649
  "PgUp/Dn: page",
@@ -2387,6 +2653,7 @@ function useHotkeys({
2387
2653
  "?: help",
2388
2654
  "q: quit"
2389
2655
  ] : [
2656
+ ...trackingItems,
2390
2657
  "Tab: projects",
2391
2658
  "\u2191\u2193/jk: scroll",
2392
2659
  "PgUp/Dn: page",
@@ -2400,21 +2667,16 @@ function useHotkeys({
2400
2667
  return { handleInput, statusBarItems };
2401
2668
  }
2402
2669
 
2403
- // src/ui/hooks/useSlide.ts
2670
+ // src/ui/hooks/useTick.ts
2404
2671
  import { useEffect, useState } from "react";
2405
- function useSlide(active, positions, intervalMs = 180, resetKey) {
2406
- const [index, setIndex] = useState(0);
2407
- useEffect(() => {
2408
- setIndex(0);
2409
- }, [resetKey]);
2672
+ function useTick(active, intervalMs = 100) {
2673
+ const [n, setN] = useState(0);
2410
2674
  useEffect(() => {
2411
2675
  if (!active) return;
2412
- const timer = setInterval(() => {
2413
- setIndex((i) => (i + 1) % positions);
2414
- }, intervalMs);
2415
- return () => clearInterval(timer);
2416
- }, [active, positions, intervalMs]);
2417
- return index;
2676
+ const id = setInterval(() => setN((x) => x + 1), intervalMs);
2677
+ return () => clearInterval(id);
2678
+ }, [active, intervalMs]);
2679
+ return n;
2418
2680
  }
2419
2681
 
2420
2682
  // src/ui/hooks/useSpinner.ts
@@ -2435,7 +2697,7 @@ function useSpinner(active, intervalMs = 100) {
2435
2697
  // src/ui/SessionTreePanel.tsx
2436
2698
  import { homedir as homedir4 } from "os";
2437
2699
  import { Box as Box4, Text as Text4 } from "ink";
2438
- import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2700
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2439
2701
  function formatElapsed(lastModifiedMs, now = Date.now()) {
2440
2702
  const elapsed = Math.max(0, now - lastModifiedMs);
2441
2703
  const seconds = Math.floor(elapsed / 1e3);
@@ -2466,6 +2728,13 @@ function getStatusColor(status) {
2466
2728
  return "gray";
2467
2729
  }
2468
2730
  }
2731
+ function getBadge(session) {
2732
+ if (session.liveState === "working")
2733
+ return { text: "[working]", color: "green" };
2734
+ if (session.liveState === "waiting")
2735
+ return { text: "[waiting]", color: "magenta" };
2736
+ return { text: `[${session.status}]`, color: getStatusColor(session.status) };
2737
+ }
2469
2738
  function formatProjectPath(projectPath) {
2470
2739
  const home = homedir4();
2471
2740
  const raw = projectPath.startsWith(home) ? `~${projectPath.slice(home.length)}` : projectPath;
@@ -2479,8 +2748,7 @@ function SessionRow({
2479
2748
  contentWidth
2480
2749
  }) {
2481
2750
  const isParent = prefix === " ";
2482
- const statusColor = getStatusColor(session.status);
2483
- const badge = `[${session.status}]`;
2751
+ const { text: badge, color: badgeColor } = getBadge(session);
2484
2752
  const elapsed = formatElapsed(session.lastModifiedMs);
2485
2753
  const model = session.modelName ?? "";
2486
2754
  const isNonInteractive = session.nonInteractive;
@@ -2529,7 +2797,7 @@ function SessionRow({
2529
2797
  /* @__PURE__ */ jsx4(Text4, { bold: !shouldDim, children: rawName }),
2530
2798
  shortIdDisplay ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: shortIdDisplay }) : null,
2531
2799
  /* @__PURE__ */ jsx4(Text4, { children: " " }),
2532
- /* @__PURE__ */ jsx4(Text4, { color: statusColor, children: badge }),
2800
+ /* @__PURE__ */ jsx4(Text4, { color: badgeColor, children: badge }),
2533
2801
  middleText ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: middleSection }) : null,
2534
2802
  /* @__PURE__ */ jsx4(Text4, { children: gap }),
2535
2803
  /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: elapsed }),
@@ -2653,7 +2921,7 @@ function ProjectRow({
2653
2921
  dimColor: muted,
2654
2922
  children: [
2655
2923
  nameText,
2656
- pathText ? /* @__PURE__ */ jsxs4(Fragment, { children: [
2924
+ pathText ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
2657
2925
  " ",
2658
2926
  /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: pathText })
2659
2927
  ] }) : null,
@@ -2728,11 +2996,14 @@ function SessionTreePanel({
2728
2996
  hasFocus,
2729
2997
  width = DEFAULT_PANEL_WIDTH,
2730
2998
  maxRows,
2731
- expandedIds = /* @__PURE__ */ new Set()
2999
+ expandedIds = /* @__PURE__ */ new Set(),
3000
+ trackingOn = false,
3001
+ spinner = ""
2732
3002
  }) {
2733
3003
  const innerWidth = getInnerWidth(width);
2734
3004
  const contentWidth = innerWidth - 1;
2735
- const titleLine = createTitleLine("Projects", "", width);
3005
+ const titleSuffix = trackingOn ? `[LIVE ${spinner || "\u25BC"}]` : "";
3006
+ const titleLine = createTitleLine("Projects", titleSuffix, width);
2736
3007
  const bottomLine = createBottomLine(width);
2737
3008
  const totalProjectCount = projects.length + coldProjects.length;
2738
3009
  if (totalProjectCount === 0) {
@@ -2826,7 +3097,7 @@ function SessionTreePanel({
2826
3097
  }
2827
3098
 
2828
3099
  // src/ui/App.tsx
2829
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
3100
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
2830
3101
  var VIEWER_HEIGHT_FRACTION = 0.55;
2831
3102
  function subSummarySentinel(parentId) {
2832
3103
  return {
@@ -2840,7 +3111,8 @@ function subSummarySentinel(parentId) {
2840
3111
  modelName: null,
2841
3112
  subAgents: [],
2842
3113
  nonInteractive: false,
2843
- firstUserPrompt: null
3114
+ firstUserPrompt: null,
3115
+ liveState: null
2844
3116
  };
2845
3117
  }
2846
3118
  function appendSubAgentRows(result, session, expandedIds) {
@@ -2879,7 +3151,8 @@ function flattenSessions2(tree, expandedIds) {
2879
3151
  modelName: null,
2880
3152
  subAgents: [],
2881
3153
  nonInteractive: false,
2882
- firstUserPrompt: null
3154
+ firstUserPrompt: null,
3155
+ liveState: null
2883
3156
  });
2884
3157
  const shouldShowSessions = isCold ? expandedIds.has(`__expanded-${sentinelId}`) : !expandedIds.has(`__collapsed-${sentinelId}`);
2885
3158
  if (shouldShowSessions) {
@@ -2904,7 +3177,8 @@ function flattenSessions2(tree, expandedIds) {
2904
3177
  modelName: null,
2905
3178
  subAgents: [],
2906
3179
  nonInteractive: false,
2907
- firstUserPrompt: null
3180
+ firstUserPrompt: null,
3181
+ liveState: null
2908
3182
  });
2909
3183
  if (expandedIds.has("__cold__")) {
2910
3184
  for (const project of tree.coldProjects) {
@@ -2927,6 +3201,54 @@ function getSelectedActivity(acts, live, scrollOff, rows, cursorLine) {
2927
3201
  const effectiveCursor = Math.min(cursorLine, visible.length - 1);
2928
3202
  return visible[visible.length - 1 - effectiveCursor] ?? null;
2929
3203
  }
3204
+ function pickTrackingTarget(tree, selectedId, seen) {
3205
+ const ids = /* @__PURE__ */ new Set();
3206
+ const liveSubs = [];
3207
+ const liveParents = [];
3208
+ for (const p of tree.projects) {
3209
+ for (const s of p.sessions) {
3210
+ ids.add(s.id);
3211
+ if (s.status === "hot" || s.status === "warm") {
3212
+ liveParents.push({
3213
+ id: s.id,
3214
+ mtime: s.lastModifiedMs,
3215
+ isNew: !seen.has(s.id)
3216
+ });
3217
+ }
3218
+ for (const sa of s.subAgents) {
3219
+ ids.add(sa.id);
3220
+ if (sa.status === "hot" || sa.status === "warm") {
3221
+ liveSubs.push({
3222
+ id: sa.id,
3223
+ mtime: sa.lastModifiedMs,
3224
+ isNew: !seen.has(sa.id)
3225
+ });
3226
+ }
3227
+ }
3228
+ }
3229
+ }
3230
+ const newest = (xs) => xs.length === 0 ? null : xs.reduce((a, b) => a.mtime > b.mtime ? a : b).id;
3231
+ const newSubTarget = newest(liveSubs.filter((s) => s.isNew));
3232
+ if (newSubTarget) return { target: newSubTarget, ids };
3233
+ const newParentTarget = newest(liveParents.filter((p) => p.isNew));
3234
+ if (newParentTarget) return { target: newParentTarget, ids };
3235
+ const currentIsLive = selectedId != null && (liveSubs.some((s) => s.id === selectedId) || liveParents.some((p) => p.id === selectedId));
3236
+ if (currentIsLive) return { target: null, ids };
3237
+ return {
3238
+ target: newest(liveSubs) ?? newest(liveParents),
3239
+ ids
3240
+ };
3241
+ }
3242
+ function collectAllIds(tree) {
3243
+ const ids = /* @__PURE__ */ new Set();
3244
+ for (const p of tree.projects) {
3245
+ for (const s of p.sessions) {
3246
+ ids.add(s.id);
3247
+ for (const sa of s.subAgents) ids.add(sa.id);
3248
+ }
3249
+ }
3250
+ return ids;
3251
+ }
2930
3252
  function App({ mode }) {
2931
3253
  const { exit } = useApp();
2932
3254
  const { stdout } = useStdout();
@@ -2958,6 +3280,8 @@ function App({ mode }) {
2958
3280
  const [helpMode, setHelpMode] = useState3(false);
2959
3281
  const [helpScroll, setHelpScroll] = useState3(0);
2960
3282
  const helpTotalLinesRef = useRef(0);
3283
+ const [tracking, setTracking] = useState3(false);
3284
+ const seenIdsRef = useRef(/* @__PURE__ */ new Set());
2961
3285
  const allFlat = useMemo(
2962
3286
  () => flattenSessions2(sessionTree, expandedIds),
2963
3287
  [sessionTree, expandedIds]
@@ -3042,11 +3366,24 @@ function App({ mode }) {
3042
3366
  const freshConfig = loadGlobalConfig();
3043
3367
  const tree = discoverSessions(freshConfig);
3044
3368
  const updatedFlat = flattenSessions2(tree, expandedIds);
3045
- const node = updatedFlat.find((s) => s.id === selectedId);
3369
+ let nextSelected = selectedId;
3370
+ if (tracking) {
3371
+ const { target, ids } = pickTrackingTarget(
3372
+ tree,
3373
+ selectedId,
3374
+ seenIdsRef.current
3375
+ );
3376
+ if (target && target !== selectedId) {
3377
+ setSelectedId(target);
3378
+ nextSelected = target;
3379
+ }
3380
+ seenIdsRef.current = ids;
3381
+ }
3382
+ const node = updatedFlat.find((s) => s.id === nextSelected);
3046
3383
  if (!node) {
3047
3384
  const allSessions = tree.projects?.flatMap((p) => p.sessions) ?? [];
3048
3385
  const parentSession = allSessions.find(
3049
- (s) => s.subAgents.some((sa) => sa.id === selectedId)
3386
+ (s) => s.subAgents.some((sa) => sa.id === nextSelected)
3050
3387
  );
3051
3388
  if (parentSession) setSelectedId(parentSession.id);
3052
3389
  }
@@ -3059,7 +3396,7 @@ function App({ mode }) {
3059
3396
  setScrollOffset((o) => o + delta);
3060
3397
  setNewCount((n) => n + delta);
3061
3398
  }
3062
- }, [selectedId, isLive, expandedIds]);
3399
+ }, [selectedId, isLive, expandedIds, tracking]);
3063
3400
  const refreshRef = useRef(refresh);
3064
3401
  useEffect3(() => {
3065
3402
  refreshRef.current = refresh;
@@ -3094,6 +3431,11 @@ function App({ mode }) {
3094
3431
  if (debounce) clearTimeout(debounce);
3095
3432
  };
3096
3433
  }, [isWatchMode, config.refreshIntervalMs]);
3434
+ useEffect3(() => {
3435
+ if (!isWatchMode || !tracking) return;
3436
+ const timer = setInterval(() => refreshRef.current(), 1e3);
3437
+ return () => clearInterval(timer);
3438
+ }, [isWatchMode, tracking]);
3097
3439
  const filterPresets = config.filterPresets;
3098
3440
  const activePreset = useMemo(
3099
3441
  () => filterPresets[filterIndex % filterPresets.length] ?? [],
@@ -3118,26 +3460,18 @@ function App({ mode }) {
3118
3460
  const maxTreeRows = Math.floor(height * (1 - VIEWER_HEIGHT_FRACTION));
3119
3461
  const naturalTreeRows = allFlat.length;
3120
3462
  const treeRows = Math.max(1, Math.min(naturalTreeRows, maxTreeRows));
3121
- const VIEWER_BREATHING_ROWS = 1;
3122
- const viewerRows = Math.max(
3123
- 5,
3124
- height - 7 - treeRows - VIEWER_BREATHING_ROWS
3125
- );
3126
- const spinner = useSpinner(isWatchMode);
3127
- const viewerIndicatorWidth = Math.max(1, width - 3);
3128
- const liveIndicatorPosition = useSlide(
3129
- isWatchMode,
3130
- viewerIndicatorWidth,
3131
- 180,
3132
- // Reset to 0 whenever the viewer's subject changes so each new
3133
- // session/sub-agent restarts the arrow from the left.
3134
- selectedId
3135
- );
3463
+ const viewerRows = Math.max(5, height - 7 - treeRows);
3464
+ const spinner = useSpinner(isWatchMode, 150);
3465
+ const tickActive = isWatchMode && isLive && !helpMode && !detailMode && activities.length > 0;
3466
+ const liveTick = useTick(tickActive, 150);
3136
3467
  const helpViewportRows = Math.max(1, height - 3);
3137
3468
  const helpScrollStep = (delta) => {
3138
3469
  const max = Math.max(0, helpTotalLinesRef.current - helpViewportRows);
3139
3470
  setHelpScroll((s) => Math.max(0, Math.min(max, s + delta)));
3140
3471
  };
3472
+ const stopTracking = () => {
3473
+ if (tracking) setTracking(false);
3474
+ };
3141
3475
  const { handleInput, statusBarItems } = useHotkeys({
3142
3476
  focus,
3143
3477
  detailMode,
@@ -3148,12 +3482,29 @@ function App({ mode }) {
3148
3482
  },
3149
3483
  onHelpScroll: helpScrollStep,
3150
3484
  onHelpScrollToTop: () => setHelpScroll(0),
3485
+ onToggleTracking: () => {
3486
+ setTracking((on) => {
3487
+ const next = !on;
3488
+ if (next) {
3489
+ seenIdsRef.current = collectAllIds(sessionTree);
3490
+ const { target } = pickTrackingTarget(
3491
+ sessionTree,
3492
+ selectedId,
3493
+ seenIdsRef.current
3494
+ );
3495
+ if (target) setSelectedId(target);
3496
+ }
3497
+ return next;
3498
+ });
3499
+ },
3500
+ trackingOn: tracking,
3151
3501
  onSwitchFocus: () => setFocus((f) => f === "tree" ? "viewer" : "tree"),
3152
3502
  // cursorLine = "entries back from the newest" (0 = newest = bottom row).
3153
3503
  // Up arrow moves visually upward = older direction = cursorLine++.
3154
3504
  // Down arrow moves visually downward = newer direction = cursorLine--.
3155
3505
  onScrollUp: () => {
3156
3506
  if (focus === "tree") {
3507
+ stopTracking();
3157
3508
  if (selectedIndex === -1) return;
3158
3509
  const prev = Math.max(0, selectedIndex - 1);
3159
3510
  setSelectedId(allFlat[prev]?.id ?? selectedId);
@@ -3170,6 +3521,7 @@ function App({ mode }) {
3170
3521
  },
3171
3522
  onScrollDown: () => {
3172
3523
  if (focus === "tree") {
3524
+ stopTracking();
3173
3525
  if (selectedIndex === -1) return;
3174
3526
  const next = Math.min(allFlat.length - 1, selectedIndex + 1);
3175
3527
  setSelectedId(allFlat[next]?.id ?? selectedId);
@@ -3193,6 +3545,7 @@ function App({ mode }) {
3193
3545
  // PgDn = visually down = newer direction = scrollOffset--
3194
3546
  onScrollPageUp: () => {
3195
3547
  if (focus === "tree") {
3548
+ stopTracking();
3196
3549
  const prev = Math.max(0, selectedIndex - 5);
3197
3550
  setSelectedId(allFlat[prev]?.id ?? selectedId);
3198
3551
  } else {
@@ -3205,6 +3558,7 @@ function App({ mode }) {
3205
3558
  },
3206
3559
  onScrollPageDown: () => {
3207
3560
  if (focus === "tree") {
3561
+ stopTracking();
3208
3562
  const next = Math.min(allFlat.length - 1, selectedIndex + 5);
3209
3563
  setSelectedId(allFlat[next]?.id ?? selectedId);
3210
3564
  } else {
@@ -3221,6 +3575,7 @@ function App({ mode }) {
3221
3575
  },
3222
3576
  onScrollHalfPageUp: () => {
3223
3577
  if (focus === "tree") {
3578
+ stopTracking();
3224
3579
  const prev = Math.max(0, selectedIndex - Math.ceil(5 / 2));
3225
3580
  setSelectedId(allFlat[prev]?.id ?? selectedId);
3226
3581
  } else {
@@ -3236,6 +3591,7 @@ function App({ mode }) {
3236
3591
  },
3237
3592
  onScrollHalfPageDown: () => {
3238
3593
  if (focus === "tree") {
3594
+ stopTracking();
3239
3595
  const next = Math.min(
3240
3596
  allFlat.length - 1,
3241
3597
  selectedIndex + Math.ceil(5 / 2)
@@ -3296,6 +3652,7 @@ function App({ mode }) {
3296
3652
  return;
3297
3653
  }
3298
3654
  if (focus !== "tree" || !selectedId) return;
3655
+ stopTracking();
3299
3656
  if (selectedId.startsWith("__proj-") && selectedId.endsWith("__")) {
3300
3657
  const projectName = selectedId.slice(7, -2);
3301
3658
  const isCold = sessionTree.coldProjects.some(
@@ -3380,6 +3737,7 @@ function App({ mode }) {
3380
3737
  },
3381
3738
  onHide: () => {
3382
3739
  if (focus !== "tree" || !selectedId) return;
3740
+ stopTracking();
3383
3741
  if (selectedId.startsWith("__proj-") && selectedId.endsWith("__")) {
3384
3742
  const projectName = selectedId.slice(7, -2);
3385
3743
  hideProject(projectName);
@@ -3439,7 +3797,13 @@ function App({ mode }) {
3439
3797
  if (isWatchMode && (width < MIN_WIDTH || height + 1 < MIN_HEIGHT)) {
3440
3798
  return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", width, children: [
3441
3799
  /* @__PURE__ */ jsx5(Text5, { bold: true, children: "AgentHUD needs a larger terminal." }),
3442
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: `Minimum: ${MIN_WIDTH} cols \xD7 ${MIN_HEIGHT} rows` }),
3800
+ /* @__PURE__ */ jsx5(
3801
+ Text5,
3802
+ {
3803
+ dimColor: true,
3804
+ children: `Minimum: ${MIN_WIDTH} cols \xD7 ${MIN_HEIGHT} rows`
3805
+ }
3806
+ ),
3443
3807
  /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: `Current: ${width} cols \xD7 ${height + 1} rows` }),
3444
3808
  /* @__PURE__ */ jsx5(Text5, { children: " " }),
3445
3809
  /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Resize the window and AgentHUD will redraw automatically." }),
@@ -3474,7 +3838,7 @@ function App({ mode }) {
3474
3838
  helpTotalLinesRef.current = n;
3475
3839
  }
3476
3840
  }
3477
- ) : /* @__PURE__ */ jsxs5(Fragment2, { children: [
3841
+ ) : /* @__PURE__ */ jsxs5(Fragment3, { children: [
3478
3842
  migrationWarning && /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "Config moved to ~/.agenthud/config.yaml" }) }),
3479
3843
  /* @__PURE__ */ jsx5(
3480
3844
  SessionTreePanel,
@@ -3485,7 +3849,9 @@ function App({ mode }) {
3485
3849
  hasFocus: focus === "tree",
3486
3850
  width,
3487
3851
  maxRows: treeRows,
3488
- expandedIds
3852
+ expandedIds,
3853
+ trackingOn: tracking,
3854
+ spinner
3489
3855
  }
3490
3856
  ),
3491
3857
  /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: detailMode && detailActivity ? /* @__PURE__ */ jsx5(
@@ -3506,8 +3872,8 @@ function App({ mode }) {
3506
3872
  isLive,
3507
3873
  newCount,
3508
3874
  visibleRows: viewerRows,
3509
- trailingBlankRows: VIEWER_BREATHING_ROWS,
3510
- liveIndicatorPosition,
3875
+ liveSpinnerFrame: spinner,
3876
+ liveTick,
3511
3877
  width,
3512
3878
  cursorLine: viewerCursorLine,
3513
3879
  hasFocus: focus === "viewer",
@@ -3591,7 +3957,8 @@ if (options.mode === "summary") {
3591
3957
  to: options.summaryTo,
3592
3958
  today,
3593
3959
  force: options.summaryForce ?? false,
3594
- assumeYes: options.summaryAssumeYes ?? false
3960
+ assumeYes: options.summaryAssumeYes ?? false,
3961
+ model: options.summaryModel
3595
3962
  });
3596
3963
  process.exit(exitCode2);
3597
3964
  }
@@ -3599,7 +3966,8 @@ if (options.mode === "summary") {
3599
3966
  date: options.summaryDate,
3600
3967
  prompt: options.summaryPrompt,
3601
3968
  force: options.summaryForce ?? false,
3602
- today
3969
+ today,
3970
+ model: options.summaryModel
3603
3971
  });
3604
3972
  process.exit(exitCode);
3605
3973
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenthud",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "CLI tool to monitor agent status in real-time. Works with Claude Code, multi-agent workflows, and any AI agent system.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",