@scrylog/cli 0.2.0 → 0.3.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.
Files changed (94) hide show
  1. package/README.md +46 -7
  2. package/package.json +13 -10
  3. package/src/api/docs/sessions.ts +3 -2
  4. package/src/api/docs/system.ts +3 -0
  5. package/src/api/docs/usage.ts +2 -2
  6. package/src/api/routes/sessions.ts +6 -2
  7. package/src/api/routes/system.ts +4 -1
  8. package/src/api/routes/webhooks/claude.ts +8 -0
  9. package/src/api/routes/webhooks/opencode.ts +1 -0
  10. package/src/api/schemas/common.ts +8 -0
  11. package/src/api/schemas/sessions.ts +15 -1
  12. package/src/api/schemas/usage.ts +1 -0
  13. package/src/cli/commands.ts +85 -3
  14. package/src/cli/index.ts +18 -0
  15. package/src/cli/program.ts +175 -0
  16. package/src/daemon/collectors/claude/collector.ts +1 -1
  17. package/src/daemon/collectors/claude/discovery.ts +2 -23
  18. package/src/daemon/collectors/codex/collector.ts +3 -3
  19. package/src/daemon/collectors/git.ts +60 -2
  20. package/src/daemon/collectors/herdr/collector.ts +128 -0
  21. package/src/daemon/collectors/herdr/index.ts +2 -0
  22. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  23. package/src/daemon/collectors/opencode-db.ts +17 -5
  24. package/src/daemon/collectors/tmux/collector.ts +21 -1
  25. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  26. package/src/daemon/collectors/usage.ts +115 -49
  27. package/src/daemon/index.ts +2 -0
  28. package/src/daemon/store.ts +44 -2
  29. package/src/postinstall.cjs +11 -0
  30. package/src/shared/config.ts +33 -0
  31. package/src/shared/session-exclusions.ts +5 -0
  32. package/src/shared/session-view.ts +67 -6
  33. package/src/shared/terminal-capabilities.ts +130 -0
  34. package/src/shared/types.ts +33 -2
  35. package/src/shared/version.ts +44 -0
  36. package/src/tui/App.tsx +240 -0
  37. package/src/tui/assets/providers/SOURCES.md +21 -0
  38. package/src/tui/assets/providers/claude-terminal.png +0 -0
  39. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  40. package/src/tui/assets/providers/claude.png +0 -0
  41. package/src/tui/assets/providers/copilot.png +0 -0
  42. package/src/tui/assets/providers/copilot.svg +44 -0
  43. package/src/tui/assets/providers/openai-terminal.png +0 -0
  44. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  45. package/src/tui/assets/providers/openai.png +0 -0
  46. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  47. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  48. package/src/tui/assets/providers/opencode.png +0 -0
  49. package/src/tui/assets/providers/shell.png +0 -0
  50. package/src/tui/assets/providers/shell.svg +23 -0
  51. package/src/tui/assets/providers/unknown.png +0 -0
  52. package/src/tui/assets/providers/unknown.svg +23 -0
  53. package/src/tui/client.ts +22 -8
  54. package/src/tui/components/BindingsModal.tsx +82 -0
  55. package/src/tui/components/DashboardHeader.tsx +256 -0
  56. package/src/tui/components/KeyBar.tsx +67 -0
  57. package/src/tui/components/SessionDetailModal.tsx +169 -0
  58. package/src/tui/components/SessionsView.tsx +184 -0
  59. package/src/tui/components/TabBar.tsx +40 -0
  60. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  61. package/src/tui/components/UsageView.tsx +242 -0
  62. package/src/tui/components/tool-icons.ts +24 -0
  63. package/src/tui/index.tsx +106 -0
  64. package/src/tui/keybindings.ts +49 -0
  65. package/src/tui/model.ts +295 -0
  66. package/src/tui/state.ts +77 -0
  67. package/src/tui/theme.ts +110 -0
  68. package/src/usage/README.md +3 -3
  69. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  70. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  71. package/src/usage/clients/claude-cli-probe.ts +458 -132
  72. package/src/usage/clients/codex-app-server.ts +354 -0
  73. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  74. package/src/usage/clients/opencode-go-api.ts +100 -0
  75. package/src/usage/clients/opencode-go-usage.ts +163 -27
  76. package/src/usage/errors.ts +33 -0
  77. package/src/usage/local/opencode-go-local.ts +194 -0
  78. package/src/usage/mappers.ts +24 -15
  79. package/src/usage/planner.ts +89 -25
  80. package/src/usage/providers/claude.ts +203 -83
  81. package/src/usage/providers/codex.ts +92 -11
  82. package/src/usage/providers/opencode-go.ts +94 -16
  83. package/src/usage/resolvers/browser-cookies.ts +407 -69
  84. package/src/usage/resolvers/claude-auth.ts +140 -40
  85. package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
  86. package/src/usage/resolvers/claude-web.ts +72 -6
  87. package/src/usage/resolvers/codex-auth.ts +74 -17
  88. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  89. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  90. package/src/usage/types.ts +37 -2
  91. package/src/usage/web-session-cache.ts +37 -0
  92. package/src/neo-blessed.d.ts +0 -4
  93. package/src/tui/app.ts +0 -920
  94. package/src/tui/index.ts +0 -217
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # scrylog
2
2
 
3
- `scrylog` is a Bun-based terminal dashboard and daemon for monitoring AI coding sessions in `tmux` and as standalone processes.
3
+ `scrylog` is a Bun-based terminal dashboard and daemon for monitoring AI coding sessions in `tmux`, herdr, and as standalone processes.
4
4
 
5
5
  It ships four commands:
6
6
 
@@ -14,12 +14,14 @@ It ships four commands:
14
14
  ## Features
15
15
 
16
16
  - `tmux` monitoring for OpenCode, Codex, Copilot, and shell panes
17
+ - herdr default-session monitoring through its structured agent and pane API
17
18
  - OpenCode integration in `hooks` or `sse` mode
18
19
  - Claude transcript collection
19
20
  - Codex transcript collection and hook-based state tracking
20
21
  - Local daemon with Unix socket IPC on macOS and Linux, TCP fallback on Windows
21
22
  - Optional TCP REST API exposure (enabled by default on Windows)
22
23
  - Optional MCP HTTP exposure with bearer token support
24
+ - Responsive OpenTUI dashboard with Sessions and provider-usage views
23
25
  - User-service installation on macOS and Linux
24
26
  - Interactive setup wizard and diagnostics
25
27
 
@@ -27,6 +29,7 @@ It ships four commands:
27
29
 
28
30
  - [Bun](https://bun.sh) `>= 1.3.0`
29
31
  - `tmux` if you want `tmux` monitoring
32
+ - [herdr](https://herdr.dev) if you want herdr monitoring; absence is handled automatically
30
33
  - OpenCode if you want OpenCode monitoring
31
34
  - `ps` on Unix or `wmic` on Windows for process auto-discovery
32
35
 
@@ -38,6 +41,35 @@ It ships four commands:
38
41
  | Tier 2 | Linux / WSL 2 | Compatible | Claude OAuth via `~/.claude/.credentials.json`, Chrome/Chromium cookies, systemd service when available |
39
42
  | Tier 3 | Windows (native) | Experimental / not recommended | TCP IPC, `wmic` process scan, degraded service status. Chrome cookie decryption not available (DPAPI not implemented). Primary use is ad-hoc debugging, not daily monitoring. |
40
43
 
44
+ ## Terminal Icon Rendering
45
+
46
+ The Usage view renders provider icons with one of these values for `tui.providerIcons`:
47
+
48
+ | Mode | Behavior |
49
+ | --- | --- |
50
+ | `blocks` (default) | Bundled pixel-art PNGs rendered with Unicode half-blocks on the Usage cards. Works in any terminal, including multiplexers. |
51
+ | `graphics` | Full artwork. Probes the terminal (`XTVERSION`, kitty graphics query, Sixel support) and falls back to `blocks` when nothing is confirmed. |
52
+ | `kitty` / `sixel` | Force a specific image protocol even when detection does not confirm it (useful for terminals whose detection response is ambiguous). |
53
+ | `text` | Two-letter provider monogram on Usage cards. Never uses images. |
54
+
55
+ In the sessions list the Tool column also depends on the mode: image modes render 2x1-cell mini icons, `blocks` shows the colored brand monogram (`CL`, `CX`, `OC`, ...) plus child-session badges, and `text` keeps the full tool name. Press `?` in the dashboard to see a legend mapping each icon or monogram to its tool name (legend shown only when an image mode is active).
56
+
57
+ Compatibility of `graphics` by environment:
58
+
59
+ | Environment | Works | Detail |
60
+ | --- | --- | --- |
61
+ | Ghostty | Yes | Kitty graphics protocol (`TERM=xterm-ghostty`) |
62
+ | Kitty | Yes | Kitty graphics protocol (reference implementation) |
63
+ | WezTerm | Yes | Kitty graphics and Sixel |
64
+ | iTerm2 | Yes | Inline images and Sixel |
65
+ | Konsole, Foot | Yes | Sixel, requires pixel-resolution detection to confirm |
66
+ | Direct SSH (no multiplexer) | Yes | Inherits the local terminal's protocol support |
67
+ | tmux / herdr | Forced `blocks` | The multiplexer sits between the client and the terminal; passthrough is not reliable, so rendering degrades to `blocks` |
68
+ | Alacritty, Terminal.app, GNOME Terminal | No | No image protocol; falls back to `blocks` |
69
+ | Windows native (Tier 3) | No | Sixel support is partial/experimental; falls back to `blocks` |
70
+
71
+ Run `scrylog doctor` for a "Terminal graphics" section with detected capabilities and the recommended value for the current terminal.
72
+
41
73
  ## WSL 2 Notes
42
74
 
43
75
  - The daemon runs as a Linux process and uses the same paths as native Linux.
@@ -130,8 +162,11 @@ For most users on macOS or Linux:
130
162
  ```bash
131
163
  scrylog
132
164
  scrylog --config ~/.config/scrylog/config.json
165
+ scrylog --include-unknown
133
166
  ```
134
167
 
168
+ By default, sessions whose tool could not be identified (`tool: "unknown"`) are hidden from the dashboard and API. Launch with `--include-unknown` to request them from the daemon for the current run.
169
+
135
170
  Keyboard shortcuts:
136
171
 
137
172
  | Key | Action |
@@ -139,7 +174,7 @@ Keyboard shortcuts:
139
174
  | `↑` / `k` | Move selection up |
140
175
  | `↓` / `j` | Move selection down |
141
176
  | `Enter` | Open session detail |
142
- | `t` | Attach to selected `tmux` session |
177
+ | `t` | Attach to selected `tmux` or herdr session |
143
178
  | `/` | Search |
144
179
  | `f` | Cycle filters |
145
180
  | `a` | Show actionable sessions only |
@@ -299,7 +334,8 @@ Example config:
299
334
  "refreshMs": 5000,
300
335
  "showStandalone": true,
301
336
  "usePushUpdates": true,
302
- "standaloneMaxAgeHours": 6
337
+ "standaloneMaxAgeHours": 6,
338
+ "providerIcons": "blocks"
303
339
  },
304
340
  "mcp": {
305
341
  "stdioEnabled": true,
@@ -336,7 +372,10 @@ Notes:
336
372
  - `daemon.commandTimeoutMs` bounds daemon-owned commands such as `ps`, `tmux`, `git`, `lsof`, and `pgrep`.
337
373
  - `daemon.networkTimeoutMs` bounds daemon-owned usage/integration network requests.
338
374
  - `usage.enabled: false` disables periodic Claude/OpenCode usage probes without disabling session monitoring.
375
+ - In the dashboard, use `Tab`, `u`, or `s` to switch between Sessions and Usage. Every usage window uses the complete card width. When provider cards exceed the terminal height, scroll with `↑`/`↓`, `j`/`k`, Page Up/Page Down, or the mouse wheel. `tui.providerIcons` accepts `blocks` (portable pixel-art PNGs, the default), `graphics` (official artwork with automatic kitty/Sixel negotiation), `kitty` or `sixel` (force a specific image protocol), or `text` (two-letter monogram); all assets are bundled for offline rendering. See [Terminal icon rendering](#terminal-icon-rendering) for environment compatibility.
339
376
  - `retention` limits in-memory state. Active `working` and `waiting-input` sessions are preserved even when old.
377
+ - `retention` applies only to standalone sessions: tmux and herdr panes are never pruned by age or count, and `tool: "unknown"` sessions do not consume `retention.maxSessions` capacity (unknown standalone sessions are still pruned by age).
378
+ - Native agent collectors remain authoritative for state and summaries, but matching sessions retain `source: "herdr"` and herdr attach metadata while their pane remains in a successful herdr snapshot. Tmux takes precedence for nested sessions.
340
379
  - The daemon log file is always JSONL and rotates by size: `scrylogd.log`, `scrylogd.1.log`, `scrylogd.2.log` by default.
341
380
  - The current CLI preserves an existing legacy config path when updating config, so older `~/.scrylog.json` users are not forced onto a new file immediately.
342
381
 
@@ -346,7 +385,7 @@ Notes:
346
385
 
347
386
  | Setting | Default | Effect |
348
387
  | --- | ---: | --- |
349
- | `retention.maxSessions` | `500` | Prunes oldest inactive sessions above this count |
388
+ | `retention.maxSessions` | `500` | Prunes oldest inactive identified standalone sessions above this count (tmux/herdr/unknown rows never count) |
350
389
  | `retention.standaloneMaxAgeHours` | `168` | Prunes inactive standalone sessions older than 7 days |
351
390
  | `retention.cacheMaxEntries` | `1000` | Bounds internal path/project caches |
352
391
  | `retention.transcriptMaxBytes` | `10485760` | Parses only a bounded tail for very large Claude transcripts |
@@ -567,8 +606,8 @@ Endpoints:
567
606
  | Method | Path | Returns |
568
607
  | --- | --- | --- |
569
608
  | `GET` | `/api/health` | daemon health, timestamps, collector status |
570
- | `GET` | `/api/status` | visible counts, collector status, generated timestamp |
571
- | `GET` | `/api/sessions` | visible session tree plus counts |
609
+ | `GET` | `/api/status` | visible counts, collector status, generated timestamp (`?includeUnknown=true` opts in) |
610
+ | `GET` | `/api/sessions` | visible session tree plus counts (`?includeUnknown=true` opts in; unknown-tool rows hidden by default) |
572
611
  | `GET` | `/api/sessions/:id` | full session detail |
573
612
  | `GET` | `/api/sessions/:id/children` | visible child sessions |
574
613
  | `GET` | `/api/sessions/:id/messages` | recent messages when available |
@@ -834,7 +873,7 @@ Publish-readiness checks:
834
873
  bun run release:check
835
874
  ```
836
875
 
837
- Public packages are versioned with Changesets and validated through Turborepo. The release workflow creates a version PR on `main`; merging that PR publishes `@scrylog/cli` and `@scrylog/opencode-plugin` to npm.
876
+ Public packages are versioned with Changesets and validated through Turborepo. Manually running the Release workflow in `version` mode creates a version PR on `main`; merging that PR publishes `@scrylog/cli` and `@scrylog/opencode-plugin` to npm.
838
877
 
839
878
  ## OpenSpec
840
879
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrylog/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Terminal dashboard for monitoring tmux sessions running AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "bun": ">=1.3.0"
32
32
  },
33
33
  "bin": {
34
- "scrylog": "src/tui/index.ts",
34
+ "scrylog": "src/cli/index.ts",
35
35
  "scrylogd": "src/daemon/index.ts",
36
36
  "scrylog-mcp": "src/mcp/index.ts",
37
37
  "scrylog-claude-hook": "src/hooks/claude.ts",
@@ -49,29 +49,32 @@
49
49
  "scripts": {
50
50
  "clean": "rm -rf .turbo && rm -rf dist",
51
51
  "clean:node-modules": "rm -rf node_modules",
52
- "dev": "bun run src/tui/index.ts",
53
- "dev:daemon": "bun run src/daemon/index.ts",
54
- "start": "bun run src/tui/index.ts",
52
+ "dev": "bun run src/cli/index.ts",
53
+ "dev:daemon": "bun run src/daemon/index.ts --loglevel debug",
54
+ "start": "bun run src/cli/index.ts",
55
+ "postinstall": "bun src/postinstall.cjs",
55
56
  "check-types": "tsc --noEmit",
56
57
  "test": "bun test",
57
- "prepublishOnly": "bun run ../../scripts/resolve-catalogs.ts",
58
- "postpublish": "bun run ../../scripts/restore-catalogs.ts",
59
- "pack:dry-run": "bun run ../../scripts/resolve-catalogs.ts && npm pack --dry-run && bun run ../../scripts/restore-catalogs.ts"
58
+ "pack:dry-run": "bash ../../scripts/npm-pack.sh \"$PWD\" \"\" --dry-run"
60
59
  },
61
60
  "dependencies": {
62
61
  "@hono/zod-openapi": "^1.3.0",
62
+ "@joeldmtz/prtl-core": "^0.3.0",
63
63
  "@modelcontextprotocol/sdk": "^1.0.0",
64
+ "@opentui/core": "0.5.11",
65
+ "@opentui/react": "0.5.11",
64
66
  "@opencode-ai/sdk": "1.4.10",
65
67
  "@scalar/hono-api-reference": "^0.10.11",
66
68
  "@scalar/openapi-to-markdown": "^0.5.9",
69
+ "commander": "15.0.0",
67
70
  "hono": "^4.12.15",
68
- "neo-blessed": "^0.2.0",
71
+ "react": "19.2.7",
69
72
  "zod": "^4.3.6"
70
73
  },
71
74
  "devDependencies": {
72
- "@types/blessed": "^0.1.27",
73
75
  "@types/bun": "^1.3.14",
74
76
  "@types/node": "^24.3.0",
77
+ "@types/react": "19.2.17",
75
78
  "typescript": "5.9.3"
76
79
  }
77
80
  }
@@ -1,5 +1,5 @@
1
1
  import type { RouteConfig } from "@hono/zod-openapi";
2
- import { ApiErrorSchema, SessionIdParamSchema } from "../schemas/common";
2
+ import { ApiErrorSchema, IncludeUnknownQuerySchema, SessionIdParamSchema } from "../schemas/common";
3
3
  import {
4
4
  SessionsResponseSchema,
5
5
  SessionDetailResponseSchema,
@@ -16,6 +16,7 @@ export const SessionRoutes = {
16
16
  summary: "List sessions",
17
17
  description: "Returns the full session tree with counts.",
18
18
  security: [{ BearerAuth: [] }],
19
+ request: { query: IncludeUnknownQuerySchema },
19
20
  responses: {
20
21
  200: {
21
22
  content: { "application/json": { schema: SessionsResponseSchema } },
@@ -29,7 +30,7 @@ export const SessionRoutes = {
29
30
  tags: ["Sessions"],
30
31
  summary: "List session children",
31
32
  security: [{ BearerAuth: [] }],
32
- request: { params: SessionIdParamSchema },
33
+ request: { params: SessionIdParamSchema, query: IncludeUnknownQuerySchema },
33
34
  responses: {
34
35
  200: {
35
36
  content: { "application/json": { schema: SessionChildrenResponseSchema } },
@@ -1,5 +1,6 @@
1
1
  import { z } from "@hono/zod-openapi";
2
2
  import type { RouteConfig } from "@hono/zod-openapi";
3
+ import { IncludeUnknownQuerySchema } from "../schemas/common";
3
4
  import { SystemRouteKey } from "./system.keys";
4
5
 
5
6
  export const SystemRoutes = {
@@ -14,6 +15,7 @@ export const SystemRoutes = {
14
15
  "application/json": {
15
16
  schema: z.object({
16
17
  ok: z.literal(true),
18
+ version: z.string(),
17
19
  startedAt: z.number(),
18
20
  refreshedAt: z.number(),
19
21
  collectors: z.record(z.string(), z.unknown()),
@@ -30,6 +32,7 @@ export const SystemRoutes = {
30
32
  tags: ["System"],
31
33
  summary: "Session counts and collector status",
32
34
  security: [{ BearerAuth: [] }],
35
+ request: { query: IncludeUnknownQuerySchema },
33
36
  responses: {
34
37
  200: {
35
38
  content: {
@@ -8,7 +8,7 @@ export const UsageRoutes = {
8
8
  method: "get",
9
9
  path: "/usage",
10
10
  tags: ["Usage"],
11
- summary: "Get subscription usage limits",
11
+ summary: "Get subscription usage and extra-use credits",
12
12
  security: [{ BearerAuth: [] }],
13
13
  responses: {
14
14
  200: {
@@ -17,7 +17,7 @@ export const UsageRoutes = {
17
17
  schema: UsageResponseSchema,
18
18
  },
19
19
  },
20
- description: "Current usage data for all configured providers",
20
+ description: "Current limits, extra-usage spend, and balances for configured providers",
21
21
  },
22
22
  },
23
23
  },
@@ -10,18 +10,22 @@ export function createSessionsRouter(store: DaemonStore, config: ScrylogConfig):
10
10
  const router = new OpenAPIHono();
11
11
 
12
12
  router.openapi(defineRoute(SessionRouteKey.LIST_SESSIONS), (c) => {
13
- const view = buildSessionView(store.getSessions(), config);
13
+ const { includeUnknown } = c.req.valid("query");
14
+ const view = buildSessionView(store.getSessions(), config, Date.now(), { includeUnknown: includeUnknown === "true" });
14
15
  return c.json({ sessions: view.sessions, counts: view.counts, generatedAt: store.getState().refreshedAt }, 200);
15
16
  });
16
17
 
17
18
  // Must be registered before /:id to avoid the wildcard swallowing the path
18
19
  router.openapi(defineRoute(SessionRouteKey.SESSION_CHILDREN), (c) => {
19
20
  const { id } = c.req.valid("param");
21
+ const { includeUnknown } = c.req.valid("query");
20
22
  const parent = store.getSession(id);
21
23
  if (!parent) {
22
24
  throw new NotFoundError("Session", id);
23
25
  }
24
- const children = buildSessionView(store.getSessions(), config).sessions.find((s) => s.id === id)?.children ?? [];
26
+ const children = buildSessionView(store.getSessions(), config, Date.now(), {
27
+ includeUnknown: includeUnknown === "true",
28
+ }).sessions.find((s) => s.id === id)?.children ?? [];
25
29
  return c.json({ sessionId: id, children }, 200);
26
30
  });
27
31
 
@@ -6,6 +6,7 @@ import type { Logger } from "../../shared/logger";
6
6
  import { buildSessionView } from "../../shared/session-view";
7
7
  import { SystemRouteKey } from "../docs/system.keys";
8
8
  import { defineRoute } from "../lib/route";
9
+ import { getInstalledVersion } from "../../shared/version";
9
10
 
10
11
  export function createSystemRouter(store: DaemonStore, config: ScrylogConfig, logger: Logger): OpenAPIHono {
11
12
  const router = new OpenAPIHono();
@@ -14,6 +15,7 @@ export function createSystemRouter(store: DaemonStore, config: ScrylogConfig, lo
14
15
  const state = store.getState();
15
16
  return c.json({
16
17
  ok: true as const,
18
+ version: getInstalledVersion(),
17
19
  startedAt: state.startedAt,
18
20
  refreshedAt: state.refreshedAt,
19
21
  collectors: state.collectorStatus,
@@ -22,7 +24,8 @@ export function createSystemRouter(store: DaemonStore, config: ScrylogConfig, lo
22
24
 
23
25
  router.openapi(defineRoute(SystemRouteKey.STATUS), (c) => {
24
26
  const state = store.getState();
25
- const view = buildSessionView(state.sessions, config);
27
+ const { includeUnknown } = c.req.valid("query");
28
+ const view = buildSessionView(state.sessions, config, Date.now(), { includeUnknown: includeUnknown === "true" });
26
29
  return c.json({
27
30
  counts: view.counts,
28
31
  collectorStatus: state.collectorStatus,
@@ -7,6 +7,7 @@ import { isMetadataOnlyEvent } from "../../services/claude-session";
7
7
  import { ValidationError } from "../../lib/errors";
8
8
  import { WebhookRouteKey } from "../../docs/webhooks.keys";
9
9
  import { defineRoute } from "../../lib/route";
10
+ import { isKnownClaudeProbePath } from "../../../shared/session-exclusions";
10
11
 
11
12
  export function createClaudeWebhookRouter(store: DaemonStore, logger: Logger, gitCollector?: GitCollector): OpenAPIHono {
12
13
  const router = new OpenAPIHono();
@@ -44,6 +45,13 @@ export function createClaudeWebhookRouter(store: DaemonStore, logger: Logger, gi
44
45
  }
45
46
 
46
47
  const storeId = `claude:${sessionId}`;
48
+ if (isKnownClaudeProbePath(currentPath) || isKnownClaudeProbePath(transcriptPath)) {
49
+ store.markLocalCommand(sessionId);
50
+ store.removeSession(storeId);
51
+ logger.debug("webhook", "claude probe excluded", { event: "session.excluded", tool: "claude", sessionId });
52
+ return c.json({ ok: true as const, skipped: "claude-probe" }, 200);
53
+ }
54
+
47
55
  if (eventType === "SessionEnd") {
48
56
  store.markLocalCommand(sessionId);
49
57
  store.removeSession(storeId);
@@ -71,6 +71,7 @@ export function createOpencodeWebhookRouter(store: DaemonStore, config: ScrylogC
71
71
  project: resolvedProject ?? existing.project,
72
72
  parentSessionId,
73
73
  lastActivity: stateChanged ? Date.now() : Math.max(existing.lastActivity, timeUpdated),
74
+ detectionSource: "opencode-hook",
74
75
  opencode: {
75
76
  ...existing.opencode,
76
77
  sessionId: rawId,
@@ -19,3 +19,11 @@ export const SessionIdParamSchema = z.object({
19
19
  .regex(/^[a-zA-Z0-9:_\-.]+$/, "Invalid session ID")
20
20
  .openapi({ param: { name: "id", in: "path" }, example: "claude:abc-123" }),
21
21
  });
22
+
23
+ export const IncludeUnknownQuerySchema = z.object({
24
+ includeUnknown: z.enum(["true", "false"]).optional().openapi({
25
+ param: { name: "includeUnknown", in: "query" },
26
+ example: "true",
27
+ description: "Include sessions whose tool could not be identified",
28
+ }),
29
+ });
@@ -17,11 +17,23 @@ const TmuxInfoSchema = z.object({
17
17
  session: z.string(),
18
18
  window: z.string(),
19
19
  pane: z.string(),
20
+ windowName: z.string().optional(),
21
+ });
22
+
23
+ const HerdrInfoSchema = z.object({
24
+ paneId: z.string(),
25
+ terminalId: z.string().optional(),
26
+ workspaceId: z.string().optional(),
27
+ workspace: z.string().optional(),
28
+ tabId: z.string().optional(),
29
+ tab: z.string().optional(),
30
+ name: z.string().optional(),
31
+ agent: z.string().optional(),
20
32
  });
21
33
 
22
34
  export const SessionListItemSchema = z.object({
23
35
  id: z.string(),
24
- source: z.enum(["tmux", "standalone"]),
36
+ source: z.enum(["tmux", "herdr", "standalone"]),
25
37
  tool: SessionToolSchema,
26
38
  state: SessionStateSchema,
27
39
  summary: z.string(),
@@ -30,8 +42,10 @@ export const SessionListItemSchema = z.object({
30
42
  hasAttention: z.boolean().optional(),
31
43
  detectionSource: z.string().optional(),
32
44
  parentSessionId: z.string().optional(),
45
+ displayName: z.string().optional(),
33
46
  project: ProjectInfoSchema.optional(),
34
47
  tmux: TmuxInfoSchema.optional(),
48
+ herdr: HerdrInfoSchema.optional(),
35
49
  currentPath: z.string().optional(),
36
50
  activeTool: z.string().optional(),
37
51
  childCount: z.number().optional(),
@@ -16,6 +16,7 @@ export const ProviderCostSnapshotSchema = z.object({
16
16
  limit: z.number(),
17
17
  currency: z.string(),
18
18
  period: z.string(),
19
+ balance: z.number().optional(),
19
20
  resetsAt: z.string().datetime().nullable().optional(),
20
21
  updatedAt: z.string().datetime(),
21
22
  });
@@ -9,12 +9,27 @@ import { getDaemonLogPath } from "../shared/paths";
9
9
  import { createPromptSession } from "./prompt";
10
10
  import { getServiceManagerOrThrow } from "../service/manager";
11
11
  import { createDaemonFetch, getSocketPath } from "../shared/ipc";
12
- import { IS_WINDOWS, whichCommand } from "../shared/platform";
12
+ import { adviseProviderIcons, probeTerminalGraphics } from "../shared/terminal-capabilities";
13
+ import { IS_MAC, IS_WINDOWS, whichCommand } from "../shared/platform";
14
+ import { formatVersionDiagnostics, getInstalledVersion } from "../shared/version";
15
+ import { listSessions } from "@joeldmtz/prtl-core";
16
+ import type { ListSessionsOptions, PrtlListResult } from "@joeldmtz/prtl-core";
13
17
 
14
18
  export interface GlobalCliOptions {
15
19
  configPath?: string;
16
20
  }
17
21
 
22
+ type HerdrList = (options?: ListSessionsOptions) => Promise<PrtlListResult>;
23
+
24
+ export async function isHerdrAvailable(timeoutMs: number, list: HerdrList = listSessions): Promise<boolean> {
25
+ try {
26
+ const result = await list({ provider: "herdr", timeoutMs });
27
+ return result.metadata.providers.herdr?.available === true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
18
33
  export async function runDaemonCommand(action: string, options: GlobalCliOptions): Promise<void> {
19
34
  const manager = getServiceManagerOrThrow();
20
35
  if (action === "install") {
@@ -102,8 +117,32 @@ export async function runLogsCommand(_options: GlobalCliOptions): Promise<void>
102
117
  process.stdout.write(`${recent.join("\n")}\n`);
103
118
  }
104
119
 
105
- export async function runDoctor(options: GlobalCliOptions): Promise<void> {
106
- const configPath = getResolvedConfigPath(options.configPath);
120
+ interface ClaudeCredentialDiagnostics {
121
+ state: "missing" | "expired" | "valid";
122
+ }
123
+
124
+ /**
125
+ * Claude credential state for doctor diagnostics, derived from the running
126
+ * daemon's usage state. Reports only the state — never prints token material.
127
+ * Returns null when the daemon is unreachable (no line is printed).
128
+ */
129
+ async function claudeCredentialState(daemonFetch: Awaited<ReturnType<typeof createDaemonFetch>>): Promise<ClaudeCredentialDiagnostics | null> {
130
+ try {
131
+ const usage = await daemonFetch("/api/usage") as { providers?: Array<{ provider: string; status: string; errors?: string[] }> };
132
+ const claude = usage.providers?.find((provider) => provider.provider === "claude");
133
+ if (!claude) return null;
134
+ if (claude.status === "ok") return { state: "valid" };
135
+ if (claude.status === "unavailable") {
136
+ const expired = (claude.errors ?? []).some((message) => message.includes("expired") || message.includes("re-login"));
137
+ return { state: expired ? "expired" : "missing" };
138
+ }
139
+ return null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ export async function runDoctor(options: GlobalCliOptions): Promise<void> { const configPath = getResolvedConfigPath(options.configPath);
107
146
  const config = loadConfig(options.configPath);
108
147
  const daemonFetch = createDaemonFetch({
109
148
  host: config.api.host,
@@ -121,12 +160,33 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
121
160
  process.stdout.write(`Retention max sessions: ${config.retention.maxSessions}\n`);
122
161
  process.stdout.write(`Retention standalone max age hours: ${config.retention.standaloneMaxAgeHours}\n`);
123
162
  process.stdout.write(`Usage enabled: ${config.usage.enabled}\n`);
163
+ if (IS_MAC && config.usage.enabled) {
164
+ process.stdout.write(`Keychain prompts allowed: ${config.usage.auth.allowKeychainPrompts}\n`);
165
+ process.stdout.write("Keychain items the daemon may read: claude-api-oauth, Claude Code-credentials, Claude Safe Storage, per-browser Safe Storage (Chrome, Brave, Edge, Arc, Vivaldi, Chromium)\n");
166
+ process.stdout.write("Keychain note: macOS authorization is granted per item — you have about 90 seconds to answer each dialog; click \"Always Allow\" once per item. Successful reads are memoized for the daemon's lifetime, so each item prompts at most once per daemon run, and an unanswered dialog retries after 10 minutes. Set usage.auth.allowKeychainPrompts to false to suppress all keychain prompts.\n");
167
+
168
+ const claudeCredential = await claudeCredentialState(daemonFetch);
169
+ if (claudeCredential?.state === "valid") {
170
+ process.stdout.write("Claude credential: valid (OAuth session active)\n");
171
+ } else if (claudeCredential?.state === "expired") {
172
+ process.stdout.write("Claude credential: expired — run \"claude\" and log in to refresh, then restart the daemon\n");
173
+ } else if (claudeCredential?.state === "missing") {
174
+ process.stdout.write("Claude credential: not found — run \"claude\" and log in to enable the Claude OAuth usage source\n");
175
+ }
176
+ }
124
177
 
125
178
  try {
126
179
  const health = await daemonFetch("/api/health");
127
180
  process.stdout.write(`Local daemon reachable: ${health.ok}\n`);
181
+ if (health.ok) {
182
+ const body = await health.json().catch(() => ({})) as { version?: string };
183
+ process.stdout.write(formatVersionDiagnostics(getInstalledVersion(), body.version));
184
+ } else {
185
+ process.stdout.write(`CLI version: ${getInstalledVersion()}\n`);
186
+ }
128
187
  } catch {
129
188
  process.stdout.write("Local daemon reachable: false\n");
189
+ process.stdout.write(`CLI version: ${getInstalledVersion()}\n`);
130
190
  }
131
191
 
132
192
  process.stdout.write(`TCP API enabled: ${config.api.tcpEnabled}\n`);
@@ -155,6 +215,27 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
155
215
  process.stdout.write(`CORS origins: ${config.cors.origins.join(", ")}\n`);
156
216
  }
157
217
 
218
+ const graphics = await probeTerminalGraphics();
219
+ const advice = adviseProviderIcons(graphics);
220
+ process.stdout.write("\nTerminal graphics:\n");
221
+ if (graphics.probeFailed) {
222
+ const reason = graphics.probeFailedReason === "raw-unavailable"
223
+ ? "raw mode is unavailable in this stdin (try the interactive scrylog binary)"
224
+ : graphics.probeFailedReason === "no-answer"
225
+ ? "the terminal did not answer any probe (graphics detection is unknown)"
226
+ : "stdin is not an interactive TTY — attach one to test";
227
+ process.stdout.write(` Probe: unavailable (${reason})\n`);
228
+ } else {
229
+ process.stdout.write(` Terminal: ${graphics.terminalName ?? "(no XTVERSION response)"}\n`);
230
+ process.stdout.write(` TERM: ${graphics.term ?? "(unset)"}\n`);
231
+ process.stdout.write(` Multiplexer: ${graphics.multiplexer}\n`);
232
+ process.stdout.write(` Kitty graphics: ${graphics.kittyGraphics}\n`);
233
+ process.stdout.write(` Sixel: ${graphics.sixel}\n`);
234
+ process.stdout.write(` Window pixel resolution: ${graphics.pixelResolution ? `${graphics.pixelResolution.width}x${graphics.pixelResolution.height}` : "(not reported)"}\n`);
235
+ }
236
+ process.stdout.write(` providerIcons config: ${config.tui.providerIcons}\n`);
237
+ process.stdout.write(` Recommended providerIcons: ${graphics.probeFailed ? `run again from an interactive terminal (${advice} is the safe default)` : advice}\n`);
238
+
158
239
  process.stdout.write(`MCP HTTP enabled: ${config.mcp.httpEnabled}\n`);
159
240
  if (config.mcp.httpEnabled) {
160
241
  try {
@@ -184,6 +265,7 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
184
265
 
185
266
  const tmux = Bun.spawnSync(["tmux", "-V"], { stdout: "pipe", stderr: "pipe" });
186
267
  process.stdout.write(`tmux available: ${tmux.exitCode === 0}\n`);
268
+ process.stdout.write(`herdr available: ${await isHerdrAvailable(config.daemon.commandTimeoutMs)}\n`);
187
269
 
188
270
  if (config.opencode.mode === "hooks") {
189
271
  process.stdout.write("OpenCode mode: hooks\n");
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ import { CommanderError } from "commander";
3
+ import { createProgram } from "./program";
4
+
5
+ export async function main(argv = process.argv): Promise<void> {
6
+ await createProgram().parseAsync(argv);
7
+ }
8
+
9
+ if (import.meta.main) {
10
+ main().catch((error) => {
11
+ if (error instanceof CommanderError) {
12
+ process.exitCode = error.exitCode;
13
+ return;
14
+ }
15
+ process.stderr.write(`scrylog: fatal: ${error instanceof Error ? error.message : String(error)}\n`);
16
+ process.exitCode = 1;
17
+ });
18
+ }