diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c29888c3..19882aa7 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -234,7 +234,7 @@ sequenceDiagram
API->>TX: BEGIN TRANSACTION
TX->>TX: ensureSession(session_id)
- Note over TX: Creates session + main agent
if first contact. Also persists
data.transcript_path onto the session row
(SQL-guarded, so subsequent events no-op).
Syncs sessions.name from the transcript title
(custom-title > ai-title).
+ Note over TX: Creates session + main agent
if first contact. Also persists
data.transcript_path onto the session row
(SQL-guarded, so subsequent events no-op).
Syncs sessions.name from the transcript title
(custom-title > ai-title > first user prompt).
TX->>TX: Process by hook_type
Note over TX: Dispatches by hook_type. Maintains the agent and
session state machines plus the awaiting_input_since flag.
SubagentStop also triggers a JSONL scan that emits per_tool
events under each subagent. See the hook table below for
the full per_event behaviour.
@@ -342,7 +342,7 @@ graph TD
| `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) |
| `server/websocket.js` | WebSocket server on `/ws` path, 30s heartbeat with ping/pong dead connection detection, typed broadcast function. Upgrades run through the same Host-header allowlist and optional `DASHBOARD_TOKEN` check as the HTTP surface (`isWebSocketAuthorized`) |
| `server/lib/security.js` | Network-hardening module (fix for GHSA-gr74-4xfh-6jw9). `resolveHost()` picks the bind address — `127.0.0.1` by default, widened only by `DASHBOARD_HOST` (logs a warning for non-loopback binds). `hostGuard` rejects requests whose `Host` header isn't in the loopback set or `DASHBOARD_ALLOWED_HOSTS` (DNS-rebinding defense). `corsOptions()` allows only loopback origins while letting No-Origin (curl/CLI) requests through. `tokenGuard` + `isWebSocketAuthorized` enforce the optional `DASHBOARD_TOKEN` on `/api/*` and WebSocket upgrades (accepted as `Authorization: Bearer`, `x-dashboard-token`, or `?token=`; off by default). Exempt paths and token-matching helpers (`tokensMatch`, `extractToken`) live here too |
-| `routes/hooks.js` | Core event processing inside a SQLite transaction. Auto-creates sessions/agents. Handles 8 hook types: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionEnd, plus synthetic `Compaction` events. Manages the agent state machine plus the `awaiting_input_since` overlay (stamped on SessionStart for fresh CLIs, on non-error Stop, and on permission Notifications (which now also set agent status to `waiting`); cleared on UserPromptSubmit / PreToolUse / PostToolUse / SessionStart-resume / SessionEnd; SubagentStop intentionally does NOT clear it; and stamped by the 15 s watchdog on user-interrupt (Esc) recovery — see the `index.js` row — since `Esc` fires no hook). After `res.json()` returns on `SubagentStop`, fires a fire-and-forget `scanAndImportSubagents` (from `scripts/import-history.js`) that parses every `subagents/agent-*.jsonl`, pairs `tool_use` ↔ `tool_result` blocks by `tool_use_id`, and emits per-tool `PreToolUse` + `PostToolUse` events under each subagent's own `agent_id` — closes the gap where subagent-internal tool calls would otherwise never reach the events table. The same scan also reparents nested subagents under their true spawner (see the `import-history.js` row); it returns `{ created, reparented }`, and the follow-up `new_event` refetch nudge fires when **either** is non-zero so a pure re-parent (tree shape changed, no new rows) still refreshes the UI. The same scan attributes each subagent's tokens to **its own model** (resolved from the subagent transcript) and stamps `metadata.model` on the subagent row (issue #185), so a tiered pipeline (Opus orchestrator + Sonnet/Haiku subagents) is priced per real model rather than entirely at the orchestrator's rate; the parent-model bucket is skipped to avoid colliding with the main-transcript token writer's compaction baseline logic. Session reactivation on resume (including Stop/SubagentStop reactivation for imported completed/abandoned sessions), orphaned-session cleanup uses `DASHBOARD_STALE_MINUTES` (default 180). Uses a shared `TranscriptCache` instance (`server/lib/transcript-cache.js`) for extraction of tokens, API errors, turn durations, thinking blocks, and usage extras — stat-based caching with incremental byte-offset reads avoids re-reading entire JSONL files on every event. Detects compaction via `isCompactSummary` in JSONL transcripts and creates compaction agents + events (deduplicated by uuid). Token baselines (`baseline_*` columns) preserve pre-compaction totals so no usage is lost. Cache entries are evicted on SessionEnd. **SessionEnd preserves error state** — but only when the error is still unrecovered at the transcript tail (`isErrorAtTail`: latest API error with no successful turn after it); a transient error the CLI retried past finalizes as `completed` instead of freezing in a stale `error`. **Error recovery**: `UserPromptSubmit` and `PreToolUse` recover a session from `error`; additionally the 15 s watchdog now scans `error` sessions and self-heals one back to `active` when its transcript has progressed past the last API error (`isErrorAtTail` false) — closing the gap where a transient API error left an imported or sweep-monitored session (no live recovery hook) pinned in `error` forever. **Session naming**: on every event, syncs `sessions.name` from the transcript title surfaced by `TranscriptCache` and broadcasts `session_updated` — an explicit `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) always wins, an `ai-title` (auto / plan-accept) only fills a placeholder/auto name (`Session ` or a cwd-folder import name) so a user-chosen name is never clobbered. The guarded `updateSessionName` no-ops on the unchanged case, so the broadcast path stays quiet; the 15 s error-watchdog runs the same sync for idle sessions that fire no hook after a rename |
+| `routes/hooks.js` | Core event processing inside a SQLite transaction. Auto-creates sessions/agents. Handles 8 hook types: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionEnd, plus synthetic `Compaction` events. Manages the agent state machine plus the `awaiting_input_since` overlay (stamped on SessionStart for fresh CLIs, on non-error Stop, and on permission Notifications (which now also set agent status to `waiting`); cleared on UserPromptSubmit / PreToolUse / PostToolUse / SessionStart-resume / SessionEnd; SubagentStop intentionally does NOT clear it; and stamped by the 15 s watchdog on user-interrupt (Esc) recovery — see the `index.js` row — since `Esc` fires no hook). After `res.json()` returns on `SubagentStop`, fires a fire-and-forget `scanAndImportSubagents` (from `scripts/import-history.js`) that parses every `subagents/agent-*.jsonl`, pairs `tool_use` ↔ `tool_result` blocks by `tool_use_id`, and emits per-tool `PreToolUse` + `PostToolUse` events under each subagent's own `agent_id` — closes the gap where subagent-internal tool calls would otherwise never reach the events table. The same scan also reparents nested subagents under their true spawner (see the `import-history.js` row); it returns `{ created, reparented }`, and the follow-up `new_event` refetch nudge fires when **either** is non-zero so a pure re-parent (tree shape changed, no new rows) still refreshes the UI. The same scan attributes each subagent's tokens to **its own model** (resolved from the subagent transcript) and stamps `metadata.model` on the subagent row (issue #185), so a tiered pipeline (Opus orchestrator + Sonnet/Haiku subagents) is priced per real model rather than entirely at the orchestrator's rate; the parent-model bucket is skipped to avoid colliding with the main-transcript token writer's compaction baseline logic. Session reactivation on resume (including Stop/SubagentStop reactivation for imported completed/abandoned sessions), orphaned-session cleanup uses `DASHBOARD_STALE_MINUTES` (default 180). Uses a shared `TranscriptCache` instance (`server/lib/transcript-cache.js`) for extraction of tokens, API errors, turn durations, thinking blocks, and usage extras — stat-based caching with incremental byte-offset reads avoids re-reading entire JSONL files on every event. Detects compaction via `isCompactSummary` in JSONL transcripts and creates compaction agents + events (deduplicated by uuid). Token baselines (`baseline_*` columns) preserve pre-compaction totals so no usage is lost. Cache entries are evicted on SessionEnd. **SessionEnd preserves error state** — but only when the error is still unrecovered at the transcript tail (`isErrorAtTail`: latest API error with no successful turn after it); a transient error the CLI retried past finalizes as `completed` instead of freezing in a stale `error`. **Error recovery**: `UserPromptSubmit` and `PreToolUse` recover a session from `error`; additionally the 15 s watchdog now scans `error` sessions and self-heals one back to `active` when its transcript has progressed past the last API error (`isErrorAtTail` false) — closing the gap where a transient API error left an imported or sweep-monitored session (no live recovery hook) pinned in `error` forever. **Session naming**: on every event, syncs `sessions.name` from the transcript title surfaced by `TranscriptCache` and broadcasts `session_updated` — an explicit `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) always wins, an `ai-title` (auto / plan-accept) only fills a placeholder/auto name (`Session ` or a cwd-folder import name) so a user-chosen name is never clobbered. When neither title exists, the session's **first user prompt** (surfaced by `TranscriptCache` as `firstUserMessage`; tool-result / meta / slash-command plumbing entries skipped) fills the placeholder session name plus the main agent's placeholder name and empty task (issue #201) — a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through (the shared `updateAgent` statement writes that column verbatim) so it is never wiped mid-turn. The guarded `updateSessionName` no-ops on the unchanged case, so the broadcast path stays quiet; the 15 s error-watchdog runs the same sync for idle sessions that fire no hook after a rename |
| `routes/sessions.js` | Standard CRUD with pagination. GET includes agent count via LEFT JOIN. POST is idempotent on session ID. GET `/:id/transcript` also surfaces `custom-title` lines as synthetic `session_event` (rename) messages — deduped, with `ai-title` excluded — so TUI-only `/rename` (which writes no user/assistant turn) is still visible in the conversation viewer. It also surfaces `system`/`local_command` lines: newer Claude Code builds write a local slash command's invocation and captured output (``, ``/`stderr`) as `system`/`local_command` entries with the TUI markup in a top-level `content` string (older builds used `user` messages), so the route re-emits those as user-side text and the client's `tuiSegments` parser renders the command pill + its output (e.g. `/color` → a `/color` pill plus "Session color set to: cyan"). Content-less `local_command` lines (e.g. `/clear`) and every other `system` subtype (`turn_duration`, `stop_hook_summary`, …) are dropped as noise |
| `routes/agents.js` | CRUD with status/session_id filtering. PATCH broadcasts `agent_updated`. Agent-list responses (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` via `pricing.attachAgentCosts` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total |
| `routes/events.js` | Read-only event listing with session_id filter and pagination |
@@ -356,7 +356,7 @@ graph TD
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status. Per-session drill-in endpoint with agent tree, tool timeline, and event details |
-| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup |
+| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup |
| `scripts/import-history.js` | Batch history importer used by (a) server startup auto-import, (b) the `/api/import/*` routes, (c) the `import-history` CLI, and (d) live `SubagentStop` ingestion via the exported `scanAndImportSubagents(dbModule, sessionId, transcriptPath)`. Exposes `importAllSessions(dbModule)` for the default `~/.claude/projects` tree, `syncDefaultProjects(dbModule, {mtimeCache})` (the incremental, mtime-fingerprinted re-sweep that backs the continuous background sync — parses only new/changed files and reports `[{sessionId, isNew}]`), and the generalized `importFromDirectory(dbModule, rootDir, {onProgress})` which walks any directory recursively, classifies each `.jsonl` as session vs subagent (with `findSessionSubagents` probing both `//subagents/*` and `/subagents//*` layouts), and funnels everything through the shared `parseSessionFile` + `importSession` pipeline. The durable transcript snapshot (`snapshotTranscript`) additionally preserves **nested** Workflow-tool inner-agent transcripts (`subagents/workflows//agent-*.jsonl`) via the separate `findSessionWorkflowSubagents` probe — mirroring the run subpath so the read route resolves the snapshot identically to the live file, without pulling those nested agents into the flat sub-agent import (no double-count). `parseSubagentFile` extracts ordered `toolEvents` (tool_use + tool_result paired by `tool_use_id`) so `importSubagentFromJsonl` can emit per-tool `PreToolUse` + `PostToolUse` rows under each subagent's own `agent_id`. The importer dedups against live hook-created subagent rows via `findLiveSubagentForJsonl` (session + subagent_type + start-time within 30 s) so backfill never produces parallel `-jsonl-*` rows. It also **skips `importSubagents` entirely when subagent transcripts exist** — the main-transcript `Agent`-block rows (`-subagent-N`) and the transcript rows (`-jsonl-*`) would otherwise both be created and only deduped by a fragile type+timing match, doubling every subagent; when transcripts are present the richer `-jsonl-` rows are authoritative and the `-subagent-N` fallback runs only when there are none. `importSession` now also **persists `transcript_path`** on the session row (via `setSessionTranscriptPath`) so the abandon sweep, compaction scanner, and per-agent cost backfill can locate the transcript later, and **stamps each subagent's own token buckets into `metadata.tokens`** (used for per-agent cost). `backfillSubagentTokenMetadata` (a deferred, self-limiting startup pass) fills `metadata.tokens` on subagents that predate per-agent cost, deriving the transcript path from `//.jsonl` when `transcript_path` is null and covering both subagent-dir layouts — metadata-only, so it never touches session `token_usage`. `classifyJsonl` treats any file under a `subagents/` ancestor at any depth (including the `subagents/workflows//` tree) as a subagent, so workflow inner-agent transcripts are never misimported as top-level sessions. **Nested-subagent hierarchy** is rebuilt by `reconcileSubagentParents`: every subagent is inserted flat under the main agent (no single hook/JSONL carries the spawner id), then `parseSubagentFile` also returns `spawnedChildren` — the child agent ids named on each Task tool result (`toolUseResult.agentId`) — which are inverted to a child→parent map and used to repoint `parent_agent_id` (via the `setAgentParent` statement) so a subagent that spawns its own subagents nests under its true spawner instead of collapsing flat under main; any subagent no other subagent claims stays under main. It resolves both the child and parent to their live-or-jsonl DB id (mirroring `importSubagentFromJsonl`), so it also corrects the live hook heuristic's guesses once the transcripts land. Idempotent and additive (only rewrites `parent_agent_id`), it runs in all three group-import paths (`importSession` ×2, live `scanAndImportSubagents`); `scanAndImportSubagents` returns a `reparented` count alongside `created`. **Re-import is fully incremental**: for each existing session a per-event-type high-water mark (`MAX(created_at) GROUP BY event_type`) is read up-front and only JSONL entries with `ts > cutoff[type]` are inserted for Stop / PostToolUse / TurnDuration / ToolError — so long-running sessions whose transcripts grow across multiple days continue to receive new events on every re-run instead of being blocked by the old "if zero of type X then dump all" check. `sessions.ended_at` is rolled forward to the JSONL's last activity when it surpasses the stored value, and `metadata.user_messages` / `assistant_messages` / `turn_count` are refreshed on every pass. `parseSessionFile` also captures the transcript title (`custom-title` / `ai-title`) and `importSession` prefers it for `sessions.name` over the cwd-folder fallback, backfilling existing auto/placeholder names on re-import (same precedence as the live hook sync). Other idempotency keys are unchanged: `data LIKE '%"tool_use_id":"X"%'` skips any tool event already inserted, compaction agents/events dedup by uuid, API errors dedup by summary, and `baseline_*` columns preserve pre-compaction token totals. Token totals, per-model cost, compactions, subagents, tool events, API errors, and turn durations are identical to live ingestion. Creates `APIError`, `TurnDuration`, and `ToolError` event types during import; subagent tool events carry `imported: true, source: "subagent_jsonl"` in their data payload so analytics can distinguish backfilled rows when needed |
| `server/routes/import.js` | Express router for the Import History feature. Three endpoints funnel into the same pipeline: `POST /api/import/rescan` (default projects dir), `POST /api/import/scan-path` (arbitrary absolute dir with `~` expansion), `POST /api/import/upload` (multer multipart accepting `.jsonl`, `.meta.json`, `.zip`, `.tar`, `.tar.gz`, `.tgz`, `.gz`). `GET /api/import/guide` returns OS-aware instructions + archive command + default-dir stats. Each request uses a per-request temp dir (`req._ccamUploadDir` for multer staging, a separate `workDir` for extraction) that is reclaimed in `finally`. Progress is broadcast as `import.progress` websocket messages throttled at ~150 ms. Limits configurable via `CCAM_IMPORT_MAX_BYTES` / `CCAM_IMPORT_MAX_FILES` |
| `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `CCAM_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers |
diff --git a/README-CN.md b/README-CN.md
index 98e0b9bc..4bd223eb 100644
--- a/README-CN.md
+++ b/README-CN.md
@@ -279,7 +279,7 @@ Dashboard 提供全面的功能来监控和分析你的 Claude Code 会话和 Ag
|------|------|
| **Dashboard** | 两个标签页(存储于 `localStorage`):**Monitor** — 概览统计(6 张统计卡片)、可折叠子 Agent 层级的活跃 Agent 卡片、近期活动流,项目数量通过 `ResizeObserver` 动态填满视口高度。**Health** — 综合系统健康评分环(加权:0.4 × 成功率 + 0.25 × 缓存命中率 + 0.25 × (100 − 错误率) + 0.1 × (100 − 堆内存 %))、存储引擎甜甜圈图(记录分布)、缓存性能 / 错误率 / 成功率仪表、Top 8 工具调用水平条形图、子 Agent 效能条、模型 Token 分布、压缩影响统计。所有健康指标每 5 秒从 `/api/settings/info` 和 `/api/workflows` 自动刷新。所有图表均有跟随光标的工具提示并自动避免视口边缘溢出 |
| **看板** | 顶部带视图切换(在 `localStorage` 中持久化):**Agent 视图** — 4 列(工作中 / 等待中 / 已完成 / 错误),以及**会话视图** — 5 列(活跃 / 等待中 / 已完成 / 错误 / 已废弃)。**等待中**列直接映射 Agent 的持久化 `waiting` 状态 — 当 Claude Code 停在提示符前(新会话、回合之间或被权限 Notification 阻塞)时设置,在用户继续操作(UserPromptSubmit / PreToolUse)时转换为 `working`。每个列标题都有 `?` 图标的工具提示解释生命周期。每列按状态从服务端独立获取(每列实际无上限),随后客户端按每列 10 张卡片分页,附「显示更多」按钮。WebSocket 订阅范围跟随当前视图(`agent_*` 与 `session_*` 帧),切换视图后另一类的更新不会触发重新加载。 |
-| **会话** | 可搜索、可筛选、**服务端分页**的全量会话表。每次翻页请求 `/api/sessions?status=&q=&limit=10&offset=…`,因此费用计算只针对当前可见页运行——与数据库中会话总量无关。搜索框(`q=`)在服务端对 `id` / `name` / `cwd` 做不区分大小写匹配,附 300 毫秒防抖;响应包含 `total` 计数供分页器使用。状态筛选、搜索与翻页可组合。每个会话的可读**名称**从 Transcript 实时读取并保持同步——显式标题(`/rename`、`claude -n`、选择器 Ctrl+R 写入的 JSONL `custom-title` 行)优先,否则回退到自动生成的 `ai-title`;用户自定义的名称绝不会被自动标题覆盖。该名称(无名称时回退到短 ID)显示在 Agent 卡片、Dashboard、活动流以及 Run 恢复选择器上 |
+| **会话** | 可搜索、可筛选、**服务端分页**的全量会话表。每次翻页请求 `/api/sessions?status=&q=&limit=10&offset=…`,因此费用计算只针对当前可见页运行——与数据库中会话总量无关。搜索框(`q=`)在服务端对 `id` / `name` / `cwd` 做不区分大小写匹配,附 300 毫秒防抖;响应包含 `total` 计数供分页器使用。状态筛选、搜索与翻页可组合。每个会话的可读**名称**从 Transcript 实时读取并保持同步——显式标题(`/rename`、`claude -n`、选择器 Ctrl+R 写入的 JSONL `custom-title` 行)优先,否则回退到自动生成的 `ai-title`;若两者都没有,则用会话的**首条用户 prompt**(截断,并跳过 tool-result / 斜杠命令噪音)填充占位名称以及 main agent 的占位名称/任务——因此从未获得标题的会话(包括导入的会话)也能一目了然它在做什么;用户自定义的名称绝不会被自动标题覆盖。该名称(无名称时回退到短 ID)显示在 Agent 卡片、Dashboard、活动流以及 Run 恢复选择器上 |
| **会话详情** | 单会话实时概览面板,包含活跃 Agent 横幅(当前工具 + 任务)、六个统计卡片(事件数及事件/分钟速率、工具调用数、子 Agent 数、压缩次数、错误数、滚动计时的运行时长)、Top 工具使用条形图、子 Agent 类型分布、堆叠 Token 流图,以及事件类型胶囊云——所有内容均根据 Hook 事件实时刷新。下方:Agent 层级树(父/子)、完整事件时间线(多维筛选:状态、事件类型、工具、Agent、文本搜索、日期范围)、按 `tool_use_id` 进行 Pre/Post 分组、人类可读摘要块、工具感知的输入/响应渲染器(Bash 用终端、Edit 用统一 diff、Read/Write 用带行号代码、Grep 用匹配列表、MCP 工具用键值卡片),以及对话标签页:使用 markdown(标题、列表、引用块、表格、任务列表)、带行号和复制按钮的语法高亮代码块(js/ts、python、json、bash、html、css、sql、yaml、diff),以及按工具样式化的工具调用块(Bash → 终端、Edit → 旧/新并排、Write → 文件标签、Read → 路径胶囊、Grep → pattern 卡片)渲染对话记录 |
| **活动流** | 实时流式事件日志,支持暂停/恢复和分页;点击任意事件行可就地展开其完整 hook 载荷(内联 EventDetail 面板);每行右侧的专属「会话 →」按钮可直接跳转至会话详情页,不影响当前展开状态 |
| **分析** | Token 使用量、工具频率、活动热力图(居中显示、按周排列从周日开始、日期名称提示)、会话趋势、在线/离线连接指示器。加载时图表区域显示带**脉冲动画的骨架占位符**(不仅是顶部统计卡),数据到达后再渲染真实图表 |
diff --git a/README-VN.md b/README-VN.md
index 8b05935a..6820524a 100644
--- a/README-VN.md
+++ b/README-VN.md
@@ -277,7 +277,7 @@ Bảng điều khiển cung cấp một bộ tính năng toàn diện để giá
|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Bảng điều khiển** | Hai tab lưu trong `localStorage`: **Monitor** — số liệu thống kê tổng quan (6 thẻ), thẻ Agent đang hoạt động với hệ thống phân cấp Subagent có thể thu gọn, nguồn cấp dữ liệu hoạt động gần đây với số mục hiển thị tự động lấp đầy chiều cao viewport qua `ResizeObserver`. **Health** — vòng điểm sức khỏe hệ thống tổng hợp (trọng số: 0,4 × tỷ lệ thành công + 0,25 × tỷ lệ cache hit + 0,25 × (100 − tỷ lệ lỗi) + 0,1 × (100 − heap %)), biểu đồ donut phân bổ bản ghi, thước đo hiệu suất cache / tỷ lệ lỗi / tỷ lệ thành công, biểu đồ thanh ngang top 8 công cụ, thanh hiệu quả subagent, phân bổ token theo mô hình, và thống kê nén. Tự làm mới mỗi 5 giây từ `/api/settings/info` và `/api/workflows`. Tooltip theo con trỏ với phát hiện cạnh viewport trên mọi biểu đồ |
| **Bảng Kanban** | Hai chế độ với nút chuyển ở đầu trang (lưu trong `localStorage`): **Agent** — 4 cột (Đang làm / Đang chờ / Hoàn tất / Lỗi), và **Phiên** — 5 cột (Hoạt động / Đang chờ / Hoàn tất / Lỗi / Bỏ dở). Cột **Đang chờ** ánh xạ trực tiếp trạng thái lưu trữ `waiting` của Agent — được đặt khi Claude Code đang ngồi ở dòng nhập (phiên mới, giữa các lượt, hoặc đang bị chặn bởi Notification xin quyền) và chuyển sang `working` ngay khi người dùng tiếp tục (UserPromptSubmit / PreToolUse). Mỗi tiêu đề cột có biểu tượng `?` với tooltip giải thích vòng đời. Mỗi cột tìm nạp theo trạng thái từ máy chủ (không giới hạn thực tế mỗi cột), sau đó phân trang phía client với 10 thẻ mỗi cột kèm nút "Hiện thêm". Đăng ký WebSocket bám theo chế độ đang xem (`agent_*` so với `session_*`) nên cập nhật khác chế độ không gây tải lại. |
-| **Phiên** | Bảng toàn bộ phiên có tìm kiếm, bộ lọc và **phân trang phía máy chủ**. Mỗi lần đổi trang gọi `/api/sessions?status=&q=&limit=10&offset=…`, nên tính toán chi phí chỉ chạy trên trang đang hiển thị — không phụ thuộc số phiên trong CSDL. Ô tìm kiếm (`q=`) thực hiện so khớp không phân biệt hoa thường trên `id` / `name` / `cwd` ở máy chủ với debounce 300 ms; phản hồi kèm `total` cho bộ phân trang. Bộ lọc trạng thái, tìm kiếm và phân trang kết hợp với nhau. Tên dễ đọc của mỗi phiên được đọc từ bản ghi và đồng bộ thời gian thực — tiêu đề rõ ràng do người dùng đặt qua `/rename`, `claude -n` hoặc `Ctrl+R` trong picker (dòng `custom-title` của JSONL) luôn thắng, nếu không thì dùng `ai-title` tự sinh; dashboard hiển thị tên đó (lùi về ID rút gọn khi chưa đặt) trên thẻ tác nhân, Dashboard, Activity Feed và picker tiếp tục của trang Run |
+| **Phiên** | Bảng toàn bộ phiên có tìm kiếm, bộ lọc và **phân trang phía máy chủ**. Mỗi lần đổi trang gọi `/api/sessions?status=&q=&limit=10&offset=…`, nên tính toán chi phí chỉ chạy trên trang đang hiển thị — không phụ thuộc số phiên trong CSDL. Ô tìm kiếm (`q=`) thực hiện so khớp không phân biệt hoa thường trên `id` / `name` / `cwd` ở máy chủ với debounce 300 ms; phản hồi kèm `total` cho bộ phân trang. Bộ lọc trạng thái, tìm kiếm và phân trang kết hợp với nhau. Tên dễ đọc của mỗi phiên được đọc từ bản ghi và đồng bộ thời gian thực — tiêu đề rõ ràng do người dùng đặt qua `/rename`, `claude -n` hoặc `Ctrl+R` trong picker (dòng `custom-title` của JSONL) luôn thắng, nếu không thì dùng `ai-title` tự sinh, nếu vẫn chưa có thì **prompt đầu tiên của người dùng** (được cắt ngắn, bỏ qua nhiễu tool-result / lệnh slash) sẽ điền tên placeholder của phiên cùng tên/nhiệm vụ placeholder của main agent — nhờ đó các phiên không bao giờ có tiêu đề (kể cả phiên được nhập) vẫn cho biết mình đang làm gì; dashboard hiển thị tên đó (lùi về ID rút gọn khi chưa đặt) trên thẻ tác nhân, Dashboard, Activity Feed và picker tiếp tục của trang Run |
| **Chi tiết phiên** | Bảng tổng quan thời gian thực mỗi phiên với banner tác nhân đang hoạt động (công cụ + tác vụ hiện tại), sáu ô đếm (sự kiện kèm tốc độ sự kiện/phút, lượt gọi công cụ, subagent, lần nén, lỗi, thời lượng đang đếm), thanh sử dụng top công cụ, phân tích theo loại subagent, dải dòng chảy token, và đám mây chip loại sự kiện — tất cả được làm mới trực tiếp theo sự kiện hook. Bên dưới: cây phân cấp tác nhân, dòng thời gian sự kiện đầy đủ với bộ lọc đa chiều, nhóm Pre/Post theo `tool_use_id`, khối tóm tắt dễ đọc, bộ kết xuất nhận biết công cụ (terminal cho Bash, diff cho Edit, code có số dòng cho Read/Write, danh sách kết quả khớp cho Grep, thẻ key/value cho công cụ MCP), và tab Conversation hiển thị bản ghi với markdown (tiêu đề, danh sách, blockquote, bảng, danh sách công việc), khối code có syntax highlight (js/ts, python, json, bash, html, css, sql, yaml, diff) kèm số dòng và nút sao chép, cùng các khối tool call có style theo từng công cụ (Bash → terminal, Edit → cũ/mới song song, Write → nhãn file, Read → chip đường dẫn, Grep → thẻ pattern) |
| **Nguồn cấp dữ liệu hoạt động** | Nhật ký sự kiện phát trực tuyến theo thời gian thực với tính năng tạm dừng/tiếp tục và phân trang; nhấp vào bất kỳ hàng sự kiện nào để mở rộng nội dung hook payload ngay tại chỗ (bảng EventDetail nội tuyến); nút "Phiên →" chuyên biệt ở cuối mỗi hàng điều hướng trực tiếp đến chi tiết phiên mà không thu gọn feed |
| **Phân tích** | Mức sử dụng mã thông báo, tần suất công cụ, bản đồ nhiệt hoạt động (trung tâm, căn chỉnh ngày trong tuần bắt đầu từ Chủ nhật, chú thích công cụ tên ngày), xu hướng phiên, chỉ báo kết nối trực tiếp/ngoại tuyến. Khi đang tải, vùng biểu đồ hiển thị các khung xương (skeleton) nhấp nháy chứ không chỉ các ô thống kê đầu trang |
diff --git a/README.md b/README.md
index 70e8ae3c..d5a5c205 100644
--- a/README.md
+++ b/README.md
@@ -277,7 +277,7 @@ The dashboard offers a comprehensive set of features to monitor and analyze your
|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Dashboard** | Two tabs persisted in `localStorage`: **Monitor** — overview stats (6 stat cards), active agent cards with collapsible subagent hierarchy, and recent activity feed with dynamic item counts that fill available viewport height via `ResizeObserver`. **Health** — composite system health score ring (weighted: 0.4 × success rate + 0.25 × cache hit rate + 0.25 × (100 − error rate) + 0.1 × (100 − heap %)), storage engine donut chart with record distribution, cache performance / error rate / success rate gauges, tool invocation horizontal bar chart (top 8), subagent effectiveness bars, model token distribution, and compaction impact stats. All health metrics auto-refresh every 5 s from `/api/settings/info` and `/api/workflows`. Cursor-following tooltips with viewport edge detection on every chart |
| **Kanban Board** | Two views with a header toggle (persisted in `localStorage`): **Agents** — 4 columns (Working / Waiting / Completed / Error) — and **Sessions** — 5 columns (Active / Waiting / Completed / Error / Abandoned). The **Waiting** column maps directly to the persisted `waiting` status on agents — set when Claude Code is sitting at a prompt (fresh session, between turns, or blocked on a permission Notification) and transitions to `working` the moment the user resumes (UserPromptSubmit / PreToolUse). Each column header shows a `?` tooltip explaining lifecycle transitions. Cards fetch by persisted status from the server (effectively unlimited per status), then paginate client-side at 10 cards per column with a "Show more" affordance. WS subscription scopes to the active view (`agent_*` vs `session_*` frames) so off-view updates don't trigger refetches. |
-| **Sessions** | Searchable, filterable, **server-paginated** table of every recorded session. Each page click hits `/api/sessions?status=&q=&limit=10&offset=…`, so cost computation runs only over the visible page — independent of how many sessions exist in the database. The search box (`q=`) does case-insensitive matching across `id` / `name` / `cwd` on the server with a 300 ms debounce, and the response carries a `total` count for the paginator UI. Status filter, search, and pagination compose. Each session's human-readable **name** is read from the transcript and kept in sync in real time — an explicit title from `/rename`, `claude -n`, or the picker's `Ctrl+R` (the JSONL `custom-title` line) always wins, otherwise the auto-generated `ai-title` fills in; the dashboard surfaces that name (falling back to the short ID) on cards, the Dashboard, the Activity Feed, and the Run resume picker. |
+| **Sessions** | Searchable, filterable, **server-paginated** table of every recorded session. Each page click hits `/api/sessions?status=&q=&limit=10&offset=…`, so cost computation runs only over the visible page — independent of how many sessions exist in the database. The search box (`q=`) does case-insensitive matching across `id` / `name` / `cwd` on the server with a 300 ms debounce, and the response carries a `total` count for the paginator UI. Status filter, search, and pagination compose. Each session's human-readable **name** is read from the transcript and kept in sync in real time — an explicit title from `/rename`, `claude -n`, or the picker's `Ctrl+R` (the JSONL `custom-title` line) always wins, otherwise the auto-generated `ai-title` fills in, otherwise the session's **first user prompt** (truncated, with tool-result / slash-command noise skipped) fills the placeholder name and the main agent's placeholder name/task — so sessions that never get a title (including imported ones) still say what they're doing; the dashboard surfaces that name (falling back to the short ID) on cards, the Dashboard, the Activity Feed, and the Run resume picker. |
| **Session Detail** | Per-session real-time overview panel with active-agent banner (current tool + task), six tile counters (events with events/min rate, tool calls, subagents, compactions, errors, ticking duration), top-tool usage bars, subagent type breakdown, stacked token-flow strip, and event-type pill cloud — all live-refreshed on hook events. Below it: agent hierarchy tree, full event timeline with multi-dimension filters (status, event type, tool, agent, text search, date range), Pre/Post grouping by `tool_use_id`, human-readable summary block, tool-aware input/response renderers (terminal for Bash, unified diff for Edit, line-numbered code for Read/Write, match list for Grep, key/value card for MCP tools), and a Conversation tab that renders transcripts with markdown (headings, lists, blockquotes, tables, task lists), syntax-highlighted code blocks (js/ts, python, json, bash, html, css, sql, yaml, diff) with line numbers and copy-to-clipboard, and per-tool styled tool calls (Bash → terminal, Edit → side-by-side old/new, Write → file label, Read → path chip, Grep → pattern card) |
| **Activity Feed** | Real-time streaming event log with pause/resume, multi-dimension filters (same toolbar as Session Detail plus a Session filter), server-driven "Load more" pagination, debounced filter-aware live refresh preserving the loaded page size, grouping toggle, origin prefix showing project › session › subagent, and a "Session →" button per row |
| **Analytics** | Token usage, tool frequency, activity heatmap (centered, day-of-week aligned starting Sunday, day-name tooltips), session trends, live/offline connection indicator. While the analytics payload loads, the chart region (not just the stat tiles) shows **pulsing skeleton placeholders** that mirror the chart layout, so the page never flashes empty/zero charts |
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
index bb9205d9..4be23506 100644
--- a/docs/DATABASE.md
+++ b/docs/DATABASE.md
@@ -171,7 +171,7 @@ CREATE TABLE sessions (
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | TEXT | NO | Session UUID (assigned by Claude Code) |
-| `name` | TEXT | YES | Human-readable label. Synced from the transcript title by `routes/hooks.js` (and the 15 s watchdog) on every event: the `custom-title` line (`/rename`, `claude -n`, picker `Ctrl+R`) always wins, otherwise the auto-generated `ai-title` fills a placeholder/auto name. Falls back to `Session ` |
+| `name` | TEXT | YES | Human-readable label. Synced from the transcript title by `routes/hooks.js` (and the 15 s watchdog) on every event: the `custom-title` line (`/rename`, `claude -n`, picker `Ctrl+R`) always wins, otherwise the auto-generated `ai-title` fills a placeholder/auto name, otherwise the session's first user prompt (60-char label) fills it. Falls back to `Session ` |
| `status` | TEXT | NO | `active`, `completed`, `error`, or `abandoned` (CHECK-constrained) |
| `cwd` | TEXT | YES | Working directory the CLI was launched from |
| `model` | TEXT | YES | Claude model ID (e.g. `claude-opus-4-7`) |
diff --git a/docs/HOOKS.md b/docs/HOOKS.md
index 1e70d337..1f5ef352 100644
--- a/docs/HOOKS.md
+++ b/docs/HOOKS.md
@@ -642,7 +642,7 @@ On every event that carries a `transcript_path`, the shared `TranscriptCache` re
- **Tokens / cost** — usage is accumulated per model bucket (compaction-aware baselines).
- **Model** — the most recent assistant entry's model keeps `sessions.model` current after a `/model` switch.
-- **Name** — the session title is read from the transcript: the `custom-title` line (`/rename`, `claude -n`, picker `Ctrl+R`) always wins, otherwise the auto-generated `ai-title` fills a placeholder/auto name (so a user-chosen name is never clobbered). `sessions.name` is updated via a no-op-guarded statement and a `session_updated` broadcast fires only on a real change, so the dashboard reflects renames in real time. The 15 s error-detection watchdog runs the same sync for active sessions left idle right after a `/rename`.
+- **Name** — the session title is read from the transcript: the `custom-title` line (`/rename`, `claude -n`, picker `Ctrl+R`) always wins, otherwise the auto-generated `ai-title` fills a placeholder/auto name (so a user-chosen name is never clobbered). When neither title exists, the session's **first user prompt** (tool-result, meta/caveat, and slash-command plumbing entries skipped; whitespace-collapsed, 60-char label) fills the placeholder session name plus the main agent's placeholder name and empty task — a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through so it is never wiped mid-turn. `sessions.name` is updated via a no-op-guarded statement and a `session_updated` broadcast fires only on a real change, so the dashboard reflects renames in real time. The 15 s error-detection watchdog runs the same sync for active sessions left idle right after a `/rename`.
### User interrupts (Esc) — no hook fires
diff --git a/index.html b/index.html
index b18294c2..5e9bef41 100644
--- a/index.html
+++ b/index.html
@@ -2781,7 +2781,9 @@ History import & backfill
Rescan ~/.claude/projects/, point at any directory, or drag-drop a
.tar.gz archive of transcripts. Imports are idempotent
— per-event-type high-water marks dedupe re-runs — with WebSocket progress, archive
- extraction limits, and full subagent JSONL parsing. A background sync watches the
+ extraction limits, and full subagent JSONL parsing. Sessions are named from their
+ transcript title — or their first user prompt when no title exists — instead of
+ placeholder IDs. A background sync watches the
projects folder and auto-imports new or changed sessions live — even ones that never
fire hooks — so projects added after launch appear without a manual rescan.
diff --git a/scripts/import-history.js b/scripts/import-history.js
index cbba4532..2daa4b5f 100644
--- a/scripts/import-history.js
+++ b/scripts/import-history.js
@@ -29,6 +29,7 @@ const {
getProjectsDir,
getTranscriptSnapshotDir,
} = require("../server/lib/claude-home");
+const { extractFirstUserText } = require("../server/lib/transcript-cache");
const CLAUDE_DIR = getClaudeHome();
const PROJECTS_DIR = getProjectsDir();
@@ -101,6 +102,17 @@ function copyIfNewer(src, dest) {
fs.copyFileSync(src, dest);
}
+/**
+ * 60-char display label derived from the captured first user prompt.
+ * Mirrors the truncation routes/hooks.js applies so imported and live
+ * sessions read identically. Null when there is no usable text.
+ */
+function firstUserLabel(text) {
+ const t = typeof text === "string" ? text.trim() : "";
+ if (!t) return null;
+ return t.length > 60 ? t.slice(0, 57) + "..." : t;
+}
+
/**
* Parse a single JSONL session file to extract session metadata.
*/
@@ -138,6 +150,9 @@ async function parseSessionFile(filePath) {
// metadata lines, so the last value seen wins.
let customTitle = null;
let aiTitle = null;
+ // First real user prompt (tool-result / meta / command entries skipped) —
+ // fallback descriptor for sessions that never got a title. First wins.
+ let firstUserMessage = null;
for await (const line of rl) {
if (!line.trim()) continue;
@@ -217,6 +232,10 @@ async function parseSessionFile(filePath) {
if (entry.type === "user") {
userMessageCount++;
+ if (firstUserMessage === null) {
+ const firstText = extractFirstUserText(entry);
+ if (firstText) firstUserMessage = firstText;
+ }
if (
entry.toolUseResult &&
typeof entry.toolUseResult === "object" &&
@@ -286,12 +305,13 @@ async function parseSessionFile(filePath) {
const projectName = cwd ? path.basename(cwd) : slug || `Session ${sessionId.slice(0, 8)}`;
// Prefer the real session title (custom > ai) when the transcript carries
- // one; otherwise fall back to a cwd/slug-derived label. The hook ingestor
- // applies the same precedence live, so imported and active names agree.
+ // one, then the first-user-prompt descriptor, then a cwd/slug-derived
+ // label. The hook ingestor applies the same precedence live, so imported
+ // and active names agree.
const fallbackName = slug
? `${projectName} (${slug})`
: `${projectName} - ${sessionId.slice(0, 8)}`;
- const sessionName = customTitle || aiTitle || fallbackName;
+ const sessionName = customTitle || aiTitle || firstUserLabel(firstUserMessage) || fallbackName;
// Check if the JSONL file was recently modified — indicates a possibly-active session
let fileModifiedAt = null;
@@ -307,6 +327,7 @@ async function parseSessionFile(filePath) {
name: sessionName,
customTitle,
aiTitle,
+ firstUserMessage,
cwd,
model,
version,
@@ -1343,15 +1364,20 @@ function importSession(dbModule, session) {
// Backfill the session name from the transcript title when the stored name
// is still an auto/placeholder label. Earlier imports named sessions after
- // their cwd folder; the real title (custom > ai) is more useful. Only
- // overwrite auto labels so a name the user picked is preserved.
- const transcriptTitle = session.customTitle || session.aiTitle || null;
+ // their cwd folder; the real title (custom > ai) — or, failing that, the
+ // first-user-prompt descriptor — is more useful. Only overwrite auto
+ // labels so a name the user picked is preserved. A stored name equal to
+ // the descriptor counts as auto too, so a title that appears later can
+ // still take over (descriptor sits below both title kinds).
+ const descriptorName = firstUserLabel(session.firstUserMessage);
+ const transcriptTitle = session.customTitle || session.aiTitle || descriptorName || null;
if (transcriptTitle) {
const base = session.cwd ? path.basename(session.cwd) : null;
const stored = existing.name || "";
const isAuto =
!stored.trim() ||
stored === `Session ${session.sessionId.slice(0, 8)}` ||
+ (descriptorName !== null && stored === descriptorName) ||
(base &&
(stored === base || stored.startsWith(`${base} - `) || stored.startsWith(`${base} (`)));
if (isAuto && stored !== transcriptTitle) {
@@ -1359,6 +1385,48 @@ function importSession(dbModule, session) {
backfilled = true;
}
}
+
+ // Backfill the main agent's placeholder name/task from the first user
+ // prompt, mirroring applyFirstUserDescriptor in routes/hooks.js — dead
+ // imported sessions never receive hook events, so this re-import pass is
+ // their only path to a meaningful agent label. Idempotent: once the name
+ // is non-auto and the task is set, this never writes again.
+ if (descriptorName) {
+ const mainRow = stmts.getAgent.get(`${session.sessionId}-main`);
+ if (mainRow) {
+ const base = session.cwd ? path.basename(session.cwd) : null;
+ const storedAgent = mainRow.name || "";
+ const suffix = storedAgent.startsWith("Main Agent - ")
+ ? storedAgent.slice("Main Agent - ".length)
+ : null;
+ const suffixIsAuto =
+ suffix !== null &&
+ (!suffix.trim() ||
+ suffix === `Session ${session.sessionId.slice(0, 8)}` ||
+ (base &&
+ (suffix === base ||
+ suffix.startsWith(`${base} - `) ||
+ suffix.startsWith(`${base} (`))));
+ const agentNameIsAuto = !storedAgent.trim() || storedAgent === "Main Agent" || suffixIsAuto;
+ const desiredAgentName = `Main Agent - ${descriptorName}`;
+ const fillName = agentNameIsAuto && storedAgent !== desiredAgentName;
+ const fillTask = !mainRow.task || !String(mainRow.task).trim();
+ if (fillName || fillTask) {
+ // updateAgent writes current_tool verbatim (no COALESCE) — pass the
+ // existing value through so an in-flight tool is never wiped.
+ stmts.updateAgent.run(
+ fillName ? desiredAgentName : null,
+ null,
+ fillTask ? session.firstUserMessage : null,
+ mainRow.current_tool,
+ null,
+ null,
+ mainRow.id
+ );
+ backfilled = true;
+ }
+ }
+ }
if (
session.endedAt &&
(!existing.ended_at || session.endedAt > existing.ended_at) &&
@@ -1451,7 +1519,9 @@ function importSession(dbModule, session) {
"main",
null,
agentStatus,
- null,
+ // First user prompt doubles as the main agent's task so imported agent
+ // cards say what the session set out to do (issue #201).
+ session.firstUserMessage || null,
null,
null
);
diff --git a/server/README.md b/server/README.md
index 20a1d4a0..b675dfea 100644
--- a/server/README.md
+++ b/server/README.md
@@ -437,7 +437,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
| `GET` | `/api/stats` | Dashboard aggregate counters |
| `GET` | `/api/analytics` | Analytics aggregates for charts/trends |
-**Session names** are kept in sync with the transcript title: on every hook event (and in the 15 s watchdog) the ingestor reads the latest `custom-title` (`/rename`, `claude -n`, picker `Ctrl+R`) or `ai-title` (auto) from the JSONL and updates `sessions.name` — `custom-title` always wins, `ai-title` only fills a placeholder/auto name — broadcasting `session_updated` so the UI reflects renames in real time.
+**Session names** are kept in sync with the transcript title: on every hook event (and in the 15 s watchdog) the ingestor reads the latest `custom-title` (`/rename`, `claude -n`, picker `Ctrl+R`) or `ai-title` (auto) from the JSONL and updates `sessions.name` — `custom-title` always wins, `ai-title` only fills a placeholder/auto name — broadcasting `session_updated` so the UI reflects renames in real time. When neither title exists, the session's first user prompt (tool-result / meta / slash-command plumbing entries skipped, 60-char label) fills the placeholder session name plus the main agent's placeholder name and empty task; a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through so it is never wiped mid-turn.
**Transcript stream** (`GET /api/sessions/:id/transcript`) returns `user` / `assistant` messages plus: synthetic `session_event` rename markers (from `custom-title`), and local slash-command I/O surfaced from `system`/`local_command` lines (the `` pill + ``/`stderr` output, e.g. `/color`, `/rename`, custom commands). Content-less `local_command` lines and other `system` subtypes are dropped.
diff --git a/server/__tests__/first-user-descriptor.test.js b/server/__tests__/first-user-descriptor.test.js
new file mode 100644
index 00000000..8f649c81
--- /dev/null
+++ b/server/__tests__/first-user-descriptor.test.js
@@ -0,0 +1,485 @@
+/**
+ * @file Tests for the first-user-prompt fallback descriptor (issue #201).
+ * Covers:
+ * - TranscriptCache capturing the first real user message (tool-result,
+ * meta/caveat, slash-command plumbing, and interrupt entries skipped),
+ * normalized and length-capped, first value winning across incremental
+ * re-reads.
+ * - The hook ingestor filling placeholder session/main-agent names and the
+ * main agent task from the descriptor — without clobbering real titles,
+ * user-set names, or an in-flight current_tool — and staying idempotent.
+ * - A later ai-title replacing a descriptor-filled session name.
+ * - importSession using the descriptor for imported sessions (new rows and
+ * the re-import backfill path).
+ * Uses Node's built-in test runner with temp CLAUDE_HOME / DASHBOARD_DATA_DIR.
+ * @author Son Nguyen
+ */
+
+const { describe, it, before, after } = require("node:test");
+const assert = require("node:assert/strict");
+const path = require("path");
+const fs = require("fs");
+const os = require("os");
+const http = require("http");
+
+const STAMP = `first-user-desc-${Date.now()}-${process.pid}`;
+const TMP = path.join(os.tmpdir(), STAMP);
+const CLAUDE_HOME = path.join(TMP, "home");
+const DATA_DIR = path.join(TMP, "data");
+const TEST_DB = path.join(TMP, "dashboard.db");
+process.env.DASHBOARD_DB_PATH = TEST_DB;
+process.env.CLAUDE_HOME = CLAUDE_HOME;
+process.env.DASHBOARD_DATA_DIR = DATA_DIR;
+
+const { createApp, startServer } = require("../index");
+const dbModule = require("../db");
+const { db, stmts } = dbModule;
+const TranscriptCache = require("../lib/transcript-cache");
+const { parseSessionFile, importSession } = require("../../scripts/import-history");
+
+const enc = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
+const PROJECTS = path.join(CLAUDE_HOME, "projects");
+
+function jsonl(lines) {
+ return lines.map((o) => JSON.stringify(o)).join("\n") + "\n";
+}
+
+function transcriptPath(cwd, sessionId) {
+ return path.join(PROJECTS, enc(cwd), `${sessionId}.jsonl`);
+}
+
+function writeTranscript(cwd, sessionId, lines) {
+ const p = transcriptPath(cwd, sessionId);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, jsonl(lines));
+ return p;
+}
+
+function appendTranscript(cwd, sessionId, lines) {
+ const p = transcriptPath(cwd, sessionId);
+ fs.appendFileSync(p, jsonl(lines));
+ return p;
+}
+
+function req(method, urlPath, body) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(urlPath, BASE);
+ const payload = body ? JSON.stringify(body) : null;
+ const r = http.request(
+ {
+ hostname: url.hostname,
+ port: url.port,
+ path: url.pathname + url.search,
+ method,
+ headers: payload
+ ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
+ : {},
+ },
+ (res) => {
+ let b = "";
+ res.on("data", (c) => (b += c));
+ res.on("end", () => {
+ let parsed;
+ try {
+ parsed = JSON.parse(b || "{}");
+ } catch {
+ parsed = b;
+ }
+ resolve({ status: res.statusCode, body: parsed });
+ });
+ }
+ );
+ r.on("error", reject);
+ if (payload) r.write(payload);
+ r.end();
+ });
+}
+
+let server;
+let BASE;
+
+before(async () => {
+ const app = createApp();
+ server = await startServer(app, 0);
+ BASE = `http://127.0.0.1:${server.address().port}`;
+});
+
+after(() => {
+ if (server) server.close();
+ if (db) db.close();
+ try {
+ fs.rmSync(TMP, { recursive: true, force: true });
+ } catch {
+ /* ignore */
+ }
+});
+
+describe("TranscriptCache — first user message extraction", () => {
+ it("captures the first real prompt, skipping caveat/command/tool-result entries", () => {
+ const cwd = "/tmp/fud-cache-skip";
+ const sid = "cache-skip";
+ const p = writeTranscript(cwd, sid, [
+ {
+ type: "user",
+ isMeta: true,
+ message: {
+ role: "user",
+ content: "Caveat: local commands",
+ },
+ },
+ {
+ type: "user",
+ message: { role: "user", content: "/model" },
+ },
+ {
+ type: "user",
+ message: {
+ role: "user",
+ content: "Set model",
+ },
+ },
+ {
+ type: "user",
+ message: {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "t1", content: "result text" }],
+ },
+ },
+ {
+ type: "user",
+ message: { role: "user", content: "fix the login\nbug in auth.ts" },
+ },
+ { type: "user", message: { role: "user", content: "a later prompt" } },
+ ]);
+ const r = new TranscriptCache().extract(p);
+ assert.ok(r, "result should not be null");
+ // Whitespace runs / newlines collapse to single spaces; first prompt wins.
+ assert.equal(r.firstUserMessage, "fix the login bug in auth.ts");
+ });
+
+ it("skips user-interrupt entries and caps the captured text at 500 chars", () => {
+ const cwd = "/tmp/fud-cache-cap";
+ const sid = "cache-cap";
+ const long = "x".repeat(600);
+ const p = writeTranscript(cwd, sid, [
+ {
+ type: "user",
+ message: {
+ role: "user",
+ content: [{ type: "text", text: "[Request interrupted by user]" }],
+ },
+ },
+ { type: "user", message: { role: "user", content: long } },
+ ]);
+ const r = new TranscriptCache().extract(p);
+ assert.equal(r.firstUserMessage, "x".repeat(500));
+ });
+
+ it("returns a result for a transcript that has ONLY a user prompt", () => {
+ const cwd = "/tmp/fud-cache-only";
+ const sid = "cache-only";
+ const p = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "just a prompt" } },
+ ]);
+ const r = new TranscriptCache().extract(p);
+ assert.ok(r, "result should not be null");
+ assert.equal(r.firstUserMessage, "just a prompt");
+ });
+
+ it("keeps the FIRST prompt across incremental appends (first wins)", () => {
+ const cwd = "/tmp/fud-cache-incr";
+ const sid = "cache-incr";
+ const cache = new TranscriptCache();
+ const p = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "original prompt" } },
+ ]);
+ assert.equal(cache.extract(p).firstUserMessage, "original prompt");
+ appendTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "second prompt" } },
+ ]);
+ // Force a fresh stat (mtime granularity) by touching size — append above
+ // already grew the file, so the incremental path runs.
+ assert.equal(cache.extract(p).firstUserMessage, "original prompt");
+ });
+});
+
+describe("hook ingestor — descriptor fills placeholders", () => {
+ it("fills placeholder session name and main agent name/task on UserPromptSubmit", async () => {
+ const cwd = "/tmp/fud-hook-fill";
+ const sid = "10000000-0000-0000-0000-000000000001";
+ const tpath = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "add dark mode to settings" } },
+ ]);
+ const res = await req("POST", "/api/hooks/event", {
+ hook_type: "UserPromptSubmit",
+ data: { session_id: sid, cwd, transcript_path: tpath },
+ });
+ assert.equal(res.status, 200);
+ const sess = stmts.getSession.get(sid);
+ assert.equal(sess.name, "add dark mode to settings");
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.name, "Main Agent - add dark mode to settings");
+ assert.equal(main.task, "add dark mode to settings");
+ });
+
+ it("truncates long prompts to 60 chars for names but keeps the task longer", async () => {
+ const cwd = "/tmp/fud-hook-trunc";
+ const sid = "10000000-0000-0000-0000-000000000002";
+ const prompt = "p".repeat(100);
+ const tpath = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: prompt } },
+ ]);
+ await req("POST", "/api/hooks/event", {
+ hook_type: "Stop",
+ data: { session_id: sid, cwd, transcript_path: tpath },
+ });
+ const sess = stmts.getSession.get(sid);
+ assert.equal(sess.name, "p".repeat(57) + "...");
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.name, `Main Agent - ${"p".repeat(57)}...`);
+ assert.equal(main.task, prompt);
+ });
+
+ it("title still wins: ai-title takes the session name, descriptor fills the agent", async () => {
+ const cwd = "/tmp/fud-hook-title";
+ const sid = "10000000-0000-0000-0000-000000000003";
+ const tpath = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "investigate the flaky test" } },
+ { type: "ai-title", aiTitle: "Flaky test investigation", sessionId: sid },
+ ]);
+ await req("POST", "/api/hooks/event", {
+ hook_type: "Stop",
+ data: { session_id: sid, cwd, transcript_path: tpath },
+ });
+ const sess = stmts.getSession.get(sid);
+ assert.equal(sess.name, "Flaky test investigation");
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.name, "Main Agent - investigate the flaky test");
+ assert.equal(main.task, "investigate the flaky test");
+ });
+
+ it("a later ai-title replaces a descriptor-filled session name", async () => {
+ const cwd = "/tmp/fud-hook-later-title";
+ const sid = "10000000-0000-0000-0000-000000000004";
+ const tpath = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "refactor the websocket layer" } },
+ ]);
+ await req("POST", "/api/hooks/event", {
+ hook_type: "UserPromptSubmit",
+ data: { session_id: sid, cwd, transcript_path: tpath },
+ });
+ assert.equal(stmts.getSession.get(sid).name, "refactor the websocket layer");
+
+ appendTranscript(cwd, sid, [
+ { type: "ai-title", aiTitle: "WebSocket refactor", sessionId: sid },
+ ]);
+ await req("POST", "/api/hooks/event", {
+ hook_type: "Stop",
+ data: { session_id: sid, cwd, transcript_path: tpath },
+ });
+ assert.equal(stmts.getSession.get(sid).name, "WebSocket refactor");
+ });
+
+ it("never clobbers a user-set session name or a renamed main agent", async () => {
+ const cwd = "/tmp/fud-hook-user-set";
+ const sid = "10000000-0000-0000-0000-000000000005";
+ const tpath = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "descriptor text" } },
+ ]);
+ // Seed the session, then simulate user-chosen names on both rows.
+ await req("POST", "/api/hooks/event", {
+ hook_type: "SessionStart",
+ data: { session_id: sid, cwd },
+ });
+ stmts.updateSessionName.run("my chosen name", sid, "my chosen name");
+ stmts.updateAgent.run("My Renamed Agent", null, "my task", null, null, null, `${sid}-main`);
+
+ await req("POST", "/api/hooks/event", {
+ hook_type: "Stop",
+ data: { session_id: sid, cwd, transcript_path: tpath },
+ });
+ assert.equal(stmts.getSession.get(sid).name, "my chosen name");
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.name, "My Renamed Agent");
+ assert.equal(main.task, "my task");
+ });
+
+ it("preserves an in-flight current_tool when filling the main agent", async () => {
+ const cwd = "/tmp/fud-hook-tool";
+ const sid = "10000000-0000-0000-0000-000000000006";
+ const tpath = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "run the test suite" } },
+ ]);
+ // PreToolUse stamps current_tool = Bash in the same transaction that then
+ // applies the descriptor — the fill must not wipe the in-flight tool.
+ await req("POST", "/api/hooks/event", {
+ hook_type: "PreToolUse",
+ data: { session_id: sid, cwd, transcript_path: tpath, tool_name: "Bash" },
+ });
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.current_tool, "Bash");
+ assert.equal(main.name, "Main Agent - run the test suite");
+ assert.equal(main.task, "run the test suite");
+ });
+
+ it("is idempotent — a second event changes nothing", async () => {
+ const cwd = "/tmp/fud-hook-idem";
+ const sid = "10000000-0000-0000-0000-000000000007";
+ const tpath = writeTranscript(cwd, sid, [
+ { type: "user", message: { role: "user", content: "one prompt only" } },
+ ]);
+ await req("POST", "/api/hooks/event", {
+ hook_type: "UserPromptSubmit",
+ data: { session_id: sid, cwd, transcript_path: tpath },
+ });
+ const before = {
+ session: stmts.getSession.get(sid).name,
+ agent: stmts.getAgent.get(`${sid}-main`),
+ };
+ await req("POST", "/api/hooks/event", {
+ hook_type: "Stop",
+ data: { session_id: sid, cwd, transcript_path: tpath },
+ });
+ assert.equal(stmts.getSession.get(sid).name, before.session);
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.name, before.agent.name);
+ assert.equal(main.task, before.agent.task);
+ });
+});
+
+describe("import — descriptor for imported sessions", () => {
+ it("parseSessionFile falls back to the first user prompt below titles", async () => {
+ const cwd = "/tmp/fud-import-parse";
+ const sid = "20000000-0000-0000-0000-000000000001";
+ const p = writeTranscript(cwd, sid, [
+ {
+ type: "user",
+ cwd,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ message: { role: "user", content: "build the CSV exporter" },
+ },
+ ]);
+ const parsed = await parseSessionFile(p);
+ assert.equal(parsed.firstUserMessage, "build the CSV exporter");
+ assert.equal(parsed.name, "build the CSV exporter");
+ });
+
+ it("a real title still outranks the descriptor in parseSessionFile", async () => {
+ const cwd = "/tmp/fud-import-title";
+ const sid = "20000000-0000-0000-0000-000000000002";
+ const p = writeTranscript(cwd, sid, [
+ {
+ type: "user",
+ cwd,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ message: { role: "user", content: "some prompt" },
+ },
+ { type: "ai-title", aiTitle: "Real title", sessionId: sid },
+ ]);
+ const parsed = await parseSessionFile(p);
+ assert.equal(parsed.name, "Real title");
+ assert.equal(parsed.firstUserMessage, "some prompt");
+ });
+
+ it("importSession names new rows and their main agent from the descriptor", async () => {
+ const cwd = "/tmp/fud-import-new";
+ const sid = "20000000-0000-0000-0000-000000000003";
+ const p = writeTranscript(cwd, sid, [
+ {
+ type: "user",
+ cwd,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ message: { role: "user", content: "wire up the pricing table" },
+ },
+ ]);
+ const parsed = await parseSessionFile(p);
+ importSession(dbModule, parsed);
+ const sess = stmts.getSession.get(sid);
+ assert.equal(sess.name, "wire up the pricing table");
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.name, "Main Agent - wire up the pricing table");
+ assert.equal(main.task, "wire up the pricing table");
+ });
+
+ it("re-import backfills placeholder-named rows created by earlier imports", async () => {
+ const cwd = "/tmp/fud-import-backfill";
+ const sid = "20000000-0000-0000-0000-000000000004";
+ const p = writeTranscript(cwd, sid, [
+ {
+ type: "user",
+ cwd,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ message: { role: "user", content: "migrate the alerts schema" },
+ },
+ ]);
+ // Simulate an old import: cwd-derived placeholder session + agent names.
+ // metadata.imported = true is what routes importSession to its backfill
+ // branch (hand-created sessions are never touched by re-imports).
+ const base = path.basename(cwd);
+ stmts.insertSession.run(
+ sid,
+ `${base} - ${sid.slice(0, 8)}`,
+ "completed",
+ cwd,
+ null,
+ JSON.stringify({ imported: true })
+ );
+ stmts.insertAgent.run(
+ `${sid}-main`,
+ sid,
+ `Main Agent - ${base} - ${sid.slice(0, 8)}`,
+ "main",
+ null,
+ "completed",
+ null,
+ null,
+ null
+ );
+
+ const parsed = await parseSessionFile(p);
+ const result = importSession(dbModule, parsed);
+ assert.equal(result.backfilled, true);
+ assert.equal(stmts.getSession.get(sid).name, "migrate the alerts schema");
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.name, "Main Agent - migrate the alerts schema");
+ assert.equal(main.task, "migrate the alerts schema");
+ });
+
+ it("re-import keeps user-picked names intact", async () => {
+ const cwd = "/tmp/fud-import-keep";
+ const sid = "20000000-0000-0000-0000-000000000005";
+ const p = writeTranscript(cwd, sid, [
+ {
+ type: "user",
+ cwd,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ message: { role: "user", content: "descriptor that must not apply" },
+ },
+ ]);
+ stmts.insertSession.run(
+ sid,
+ "picked by hand",
+ "completed",
+ cwd,
+ null,
+ JSON.stringify({ imported: true })
+ );
+ stmts.insertAgent.run(
+ `${sid}-main`,
+ sid,
+ "Main Agent - picked by hand",
+ "main",
+ null,
+ "completed",
+ "hand-written task",
+ null,
+ null
+ );
+ const parsed = await parseSessionFile(p);
+ importSession(dbModule, parsed);
+ assert.equal(stmts.getSession.get(sid).name, "picked by hand");
+ const main = stmts.getAgent.get(`${sid}-main`);
+ assert.equal(main.name, "Main Agent - picked by hand");
+ assert.equal(main.task, "hand-written task");
+ });
+});
diff --git a/server/lib/transcript-cache.js b/server/lib/transcript-cache.js
index 01518e0a..2c529bc6 100644
--- a/server/lib/transcript-cache.js
+++ b/server/lib/transcript-cache.js
@@ -64,6 +64,52 @@ const MAX_ARRAY_LEN = (() => {
// Amortized O(N): each item is touched by a splice at most ~once.
const PARSE_TRIM_WATERMARK = MAX_ARRAY_LEN * 2;
+// Cap on the captured first-user-message text. 500 chars matches the task
+// truncation the hook ingestor already applies to subagent prompts, so the
+// descriptor can be reused verbatim as an agent task downstream.
+const FIRST_USER_MESSAGE_MAX_LEN = 500;
+
+// Synthetic user entries whose text is CLI plumbing, not something the human
+// typed: local slash-command invocations/output and the caveat preamble
+// Claude Code writes before locally-generated messages. These must never
+// become a session descriptor.
+const SYNTHETIC_USER_TEXT_RE =
+ /^<(?:command-name|command-message|local-command-stdout|local-command-caveat)>/;
+
+/**
+ * Extract the human-typed text of a user transcript entry, or null when the
+ * entry is not a real prompt: tool-result entries, meta/caveat lines, local
+ * slash-command plumbing, compact summaries, and user-interrupt markers are
+ * all skipped. Shared with scripts/import-history.js so imported and live
+ * sessions derive the identical descriptor.
+ */
+function extractFirstUserText(entry) {
+ if (entry.isMeta || entry.isCompactSummary) return null;
+ if (entry.interruptedMessageId != null || hasInterruptText(entry.message)) return null;
+ const msg = entry.message;
+ if (!msg || typeof msg !== "object" || msg.role !== "user") return null;
+ const content = msg.content;
+ let text = null;
+ if (typeof content === "string") {
+ text = content;
+ } else if (Array.isArray(content)) {
+ // Tool-result entries are `role:"user"` too — skip any entry carrying a
+ // tool_result block rather than mining text out of a mixed payload.
+ if (content.some((b) => b && b.type === "tool_result")) return null;
+ text = content
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
+ .map((b) => b.text)
+ .join(" ");
+ }
+ if (typeof text !== "string") return null;
+ // Collapse newlines/runs of whitespace so the descriptor reads as one line.
+ text = text.replace(/\s+/g, " ").trim();
+ if (!text || SYNTHETIC_USER_TEXT_RE.test(text)) return null;
+ return text.length > FIRST_USER_MESSAGE_MAX_LEN
+ ? text.slice(0, FIRST_USER_MESSAGE_MAX_LEN)
+ : text;
+}
+
class TranscriptCache {
constructor(maxEntries = MAX_CACHE_ENTRIES) {
this._cache = new Map();
@@ -125,6 +171,7 @@ class TranscriptCache {
latestModel: merged.latestModel || null,
customTitle: merged.customTitle || null,
aiTitle: merged.aiTitle || null,
+ firstUserMessage: merged.firstUserMessage || null,
lastInterruptTs: merged.lastInterruptTs || null,
lastTurnTs: merged.lastTurnTs || null,
pendingInterrupt: computePendingInterrupt(merged.lastInterruptTs, merged.lastTurnTs),
@@ -139,6 +186,7 @@ class TranscriptCache {
!result.latestModel &&
!result.customTitle &&
!result.aiTitle &&
+ !result.firstUserMessage &&
!result.lastInterruptTs &&
!result.lastTurnTs
) {
@@ -327,6 +375,12 @@ class TranscriptCache {
// time — custom titles take precedence over ai titles.
customTitle: null,
aiTitle: null,
+ // First real user prompt of the session (tool-result / meta / command
+ // entries skipped), whitespace-collapsed and length-capped. Used
+ // downstream as a fallback descriptor for placeholder-named sessions
+ // and their main agent — first value wins (it describes what the
+ // session set out to do), unlike the last-wins titles above.
+ firstUserMessage: null,
// Timestamps (ISO 8601, all from Claude Code's clock) used to recover a
// turn cancelled with no hook. `lastInterruptTs` is the most recent
// user-interrupt (Esc) entry; `lastTurnTs` is the most recent real turn
@@ -384,6 +438,14 @@ class TranscriptCache {
state.lastTurnTs = entry.timestamp;
}
+ // First real user prompt — captured once (first wins; the file is parsed
+ // in order). extractFirstUserText filters out tool-result, meta, and
+ // slash-command plumbing entries so only human-typed text qualifies.
+ if (state.firstUserMessage === null && entry.type === "user") {
+ const firstText = extractFirstUserText(entry);
+ if (firstText) state.firstUserMessage = firstText;
+ }
+
if (entry.isCompactSummary) {
if (!state.compaction) state.compaction = { count: 0, entries: [] };
state.compaction.count++;
@@ -482,6 +544,7 @@ class TranscriptCache {
!state.latestModel &&
!state.customTitle &&
!state.aiTitle &&
+ !state.firstUserMessage &&
!state.lastInterruptTs &&
!state.lastTurnTs
) {
@@ -512,6 +575,7 @@ class TranscriptCache {
latestModel: state.latestModel,
customTitle: state.customTitle,
aiTitle: state.aiTitle,
+ firstUserMessage: state.firstUserMessage,
lastInterruptTs: state.lastInterruptTs,
lastTurnTs: state.lastTurnTs,
pendingInterrupt: computePendingInterrupt(state.lastInterruptTs, state.lastTurnTs),
@@ -619,6 +683,12 @@ class TranscriptCache {
(incremental && incremental.customTitle) || cached.result?.customTitle || null;
const aiTitle = (incremental && incremental.aiTitle) || cached.result?.aiTitle || null;
+ // First user message is first-wins (the opposite of the titles): the
+ // cached value was parsed from earlier in the file, so it stays; the
+ // incremental chunk only fills it when nothing was captured before.
+ const firstUserMessage =
+ cached.result?.firstUserMessage || (incremental && incremental.firstUserMessage) || null;
+
// Append-only: a newer interrupt / turn-activity timestamp in the
// incremental chunk supersedes the cached one, otherwise keep what was
// already known. pendingInterrupt is derived from the two by the caller.
@@ -636,6 +706,7 @@ class TranscriptCache {
latestModel,
customTitle,
aiTitle,
+ firstUserMessage,
lastInterruptTs,
lastTurnTs,
};
@@ -719,3 +790,4 @@ class TranscriptCache {
}
module.exports = TranscriptCache;
+module.exports.extractFirstUserText = extractFirstUserText;
diff --git a/server/routes/hooks.js b/server/routes/hooks.js
index ab11ea06..f7b82124 100644
--- a/server/routes/hooks.js
+++ b/server/routes/hooks.js
@@ -157,6 +157,32 @@ function isAutoSessionName(name, sessionId, cwd) {
return false;
}
+/**
+ * Short display label derived from the transcript's first user prompt.
+ * Mirrors the 60-char subagent-name truncation used in PreToolUse so
+ * descriptor-named rows read consistently across the board.
+ */
+function firstUserLabel(result) {
+ const raw = result && typeof result.firstUserMessage === "string" ? result.firstUserMessage : "";
+ const text = raw.trim();
+ if (!text) return null;
+ return text.length > 60 ? text.slice(0, 57) + "..." : text;
+}
+
+/**
+ * True when the main agent's name is still an auto-generated label:
+ * "Main Agent" (seed/API fallback) or "Main Agent - "
+ * (hook / import creation paths). A suffix that is a real title — or the
+ * user's own rename — makes the name non-auto and it is never overwritten.
+ */
+function isAutoMainAgentName(name, sessionId, cwd) {
+ if (!name || !name.trim()) return true;
+ if (name === "Main Agent") return true;
+ const prefix = "Main Agent - ";
+ if (name.startsWith(prefix)) return isAutoSessionName(name.slice(prefix.length), sessionId, cwd);
+ return false;
+}
+
/**
* Keep sessions.name in sync with the transcript's human-readable title.
* Source of truth lives in the JSONL as `custom-title` (explicit /rename,
@@ -164,8 +190,12 @@ function isAutoSessionName(name, sessionId, cwd) {
* the TranscriptCache surfaces on every extract. Precedence: an explicit
* custom title always wins; an ai-title only fills in when the current name is
* still an auto/placeholder label (so a name the user set in the dashboard or
- * via /rename is never clobbered by the auto-generated title). Broadcasts
- * session_updated only on a real change so the UI updates in real time.
+ * via /rename is never clobbered by the auto-generated title). A name that
+ * equals the first-user-prompt descriptor (applied by
+ * applyFirstUserDescriptor when no title existed yet) counts as replaceable
+ * too — the descriptor sits BELOW both title kinds, so a later ai-title must
+ * still be able to take over. Broadcasts session_updated only on a real
+ * change so the UI updates in real time.
*/
function syncSessionName(session, result) {
if (!session || !result) return;
@@ -173,7 +203,10 @@ function syncSessionName(session, result) {
const ai = result.aiTitle && result.aiTitle.trim();
const desired = custom || ai || null;
if (!desired) return;
- if (!custom && !isAutoSessionName(session.name, session.id, session.cwd)) return;
+ const replaceable =
+ isAutoSessionName(session.name, session.id, session.cwd) ||
+ session.name === firstUserLabel(result);
+ if (!custom && !replaceable) return;
const upd = stmts.updateSessionName.run(desired, session.id, desired);
if (upd.changes > 0) {
const refreshed = stmts.getSession.get(session.id);
@@ -181,6 +214,52 @@ function syncSessionName(session, result) {
}
}
+/**
+ * Fallback descriptor from the session's first user prompt (issue #201).
+ * Fills sessions.name and the main agent's name/task ONLY while those fields
+ * still hold their auto-generated placeholders, so a custom-title, ai-title,
+ * or user-set name is never clobbered. Must run strictly AFTER
+ * syncSessionName within the same pass: title sync gets first claim on the
+ * name, and this helper re-reads the rows so it sees the result. Idempotent —
+ * once applied (or once real names exist), every subsequent call is a no-op
+ * with no broadcast.
+ */
+function applyFirstUserDescriptor(sessionId, result) {
+ const label = firstUserLabel(result);
+ if (!label) return;
+
+ const session = stmts.getSession.get(sessionId);
+ if (session && isAutoSessionName(session.name, session.id, session.cwd)) {
+ const upd = stmts.updateSessionName.run(label, session.id, label);
+ if (upd.changes > 0) {
+ const refreshed = stmts.getSession.get(session.id);
+ if (refreshed) broadcast("session_updated", refreshed);
+ }
+ }
+
+ const mainAgent = getMainAgent(sessionId);
+ if (!mainAgent) return;
+ const desiredName = `Main Agent - ${label}`;
+ const fillName =
+ isAutoMainAgentName(mainAgent.name, sessionId, session?.cwd) && mainAgent.name !== desiredName;
+ const fillTask = !mainAgent.task || !String(mainAgent.task).trim();
+ if (!fillName && !fillTask) return;
+ // updateAgent writes current_tool VERBATIM (no COALESCE) — pass the
+ // existing value through, or this out-of-band update would wipe an
+ // in-flight tool mid-turn.
+ stmts.updateAgent.run(
+ fillName ? desiredName : null,
+ null,
+ fillTask ? result.firstUserMessage : null,
+ mainAgent.current_tool,
+ null,
+ null,
+ mainAgent.id
+ );
+ const refreshedAgent = stmts.getAgent.get(mainAgent.id);
+ if (refreshedAgent) broadcast("agent_updated", refreshedAgent);
+}
+
const processEvent = db.transaction((hookType, data) => {
const sessionId = data.session_id;
if (!sessionId) return null;
@@ -602,6 +681,12 @@ const processEvent = db.transaction((hookType, data) => {
// so we see any name set earlier in this same transaction.
syncSessionName(stmts.getSession.get(sessionId), result);
+ // Then let the first user prompt fill whatever placeholders remain
+ // (session name, main agent name/task). Runs on every transcript-bearing
+ // event — live on UserPromptSubmit, and as backfill for sessions that
+ // never get a title (including imported ones) on any later event.
+ applyFirstUserDescriptor(sessionId, result);
+
// Register compaction agents and events.
// Each isCompactSummary entry in the JSONL = one compaction that occurred.
// Deduplicate by uuid so we only create once per compaction.
@@ -1032,9 +1117,14 @@ function watchdogCheck() {
// Pick up a /rename or fresh ai-title even when no hook fired for it —
// a session left idle right after /rename has no further events, so the
- // watchdog is the path that surfaces the new name within ~15s.
+ // watchdog is the path that surfaces the new name within ~15s. The
+ // first-user-prompt descriptor backfills remaining placeholders here for
+ // the same reason (idle sessions get no further hook events).
const fullSess = stmts.getSession.get(sess.id);
- if (fullSess) syncSessionName(fullSess, result);
+ if (fullSess) {
+ syncSessionName(fullSess, result);
+ applyFirstUserDescriptor(sess.id, result);
+ }
const mainAgent = db
.prepare("SELECT * FROM agents WHERE session_id = ? AND type = 'main' LIMIT 1")
diff --git a/wiki/i18n-content.js b/wiki/i18n-content.js
index 2eb2a1f6..00a3b2a0 100644
--- a/wiki/i18n-content.js
+++ b/wiki/i18n-content.js
@@ -18,8 +18,8 @@ window.__WIKI_CONTENT_I18N = {
"两个标签页:Monitor 显示概览统计、带可折叠 subagent 层级的活动 agent 卡片,以及一个其条目数量会填满可用视口高度的最近活动流。Health 渲染一个综合系统健康评分环、存储引擎环形图、缓存/错误/成功仪表盘、工具调用条形图、subagent 有效性比率、模型 token 分布以及压缩统计。两个标签页都通过 WebSocket 推送每 5 秒自动刷新一次,因此无需手动重新加载视图始终保持最新。",
"Toggle between Agents (Working / Waiting / Completed / Error) and Sessions (Active / Waiting / Completed / Error / Abandoned) swim lanes. A yellow Waiting column flags items sitting on the user — fresh prompt, between turns, or permission gate. Hover any column header for lifecycle tooltips explaining each state transition. Cards surface model name, cumulative cost, and the current tool being called. Counts update in real time via WebSocket so the board is always in sync with the live event store.":
"在 Agents(Working / Waiting / Completed / Error)和 Sessions(Active / Waiting / Completed / Error / Abandoned)泳道之间切换。黄色的 Waiting 列标记出等待用户的条目——刚收到的提示、回合之间,或权限关卡。将鼠标悬停在任意列标题上可查看解释每次状态转换的生命周期提示。卡片会展示模型名称、累计成本以及当前正在调用的工具。计数通过 WebSocket 实时更新,因此看板始终与实时事件存储保持同步。",
- "Server-paginated table of every recorded session — each page fetches only its slice so cost computation stays bounded no matter how many sessions exist. Case-insensitive search across id, name, and cwd runs server-side with a 300 ms debounce; the status filter composes with search for precise narrowing. Each row shows the session's real name (synced live from the transcript — a /rename or claude -n title, else the auto title, with a short-ID fallback), status badge, agent count, duration, model, and estimated cost. Click any row to drill into the full session detail view with conversation transcript and agent hierarchy.":
- "服务端分页的表格,列出每一个已记录的会话——每一页只获取它对应的那一段数据,因此无论存在多少会话,成本计算都保持有界。针对 id、name 和 cwd 的不区分大小写搜索在服务端运行,并带有 300 ms 防抖;状态过滤器可与搜索组合以实现精确缩小范围。每一行显示会话的真实名称(从 transcript 实时同步——/rename 或 claude -n 标题,否则使用自动标题,并回退到短 ID)、状态徽章、agent 数量、时长、模型和预估成本。点击任意行即可深入查看完整的会话详情视图,包含对话记录和 agent 层级。",
+ "Server-paginated table of every recorded session — each page fetches only its slice so cost computation stays bounded no matter how many sessions exist. Case-insensitive search across id, name, and cwd runs server-side with a 300 ms debounce; the status filter composes with search for precise narrowing. Each row shows the session's real name (synced live from the transcript — a /rename or claude -n title, else the auto title, else the first user prompt, with a short-ID fallback), status badge, agent count, duration, model, and estimated cost. Click any row to drill into the full session detail view with conversation transcript and agent hierarchy.":
+ "服务端分页的表格,列出每一个已记录的会话——每一页只获取它对应的那一段数据,因此无论存在多少会话,成本计算都保持有界。针对 id、name 和 cwd 的不区分大小写搜索在服务端运行,并带有 300 ms 防抖;状态过滤器可与搜索组合以实现精确缩小范围。每一行显示会话的真实名称(从 transcript 实时同步——/rename 或 claude -n 标题,否则使用自动标题,再否则使用首条用户 prompt,并回退到短 ID)、状态徽章、agent 数量、时长、模型和预估成本。点击任意行即可深入查看完整的会话详情视图,包含对话记录和 agent 层级。",
"Per-session deep dive with a collapsible agent hierarchy tree and a full chronological event timeline showing every tool call name and summary. An overview panel at the top surfaces tile counters for events, tool calls, subagents, compactions, errors, and duration. Top-tool usage bars and a subagent type breakdown give quick distribution reads. The conversation viewer renders markdown with syntax highlighting, per-tool styled blocks, slash-command pills with their captured TUI output, and inline session-rename markers. Export the entire session as JSON or share the permalink for async review.":
"针对单个会话的深入剖析,配有可折叠的 agent 层级树和一条完整的按时间排序的事件时间线,显示每一次工具调用的名称和摘要。顶部的概览面板展示事件、工具调用、subagent、压缩、错误和时长的磁贴计数器。顶部工具使用条形图和 subagent 类型细分让你快速读取分布情况。对话查看器渲染带语法高亮的 markdown、按工具分类的样式化代码块、带其捕获的 TUI 输出的斜杠命令气泡,以及内联的会话重命名标记。可将整个会话导出为 JSON,或分享永久链接以供异步审阅。",
"A rules-based alerting engine evaluates the live event stream server-side: event pattern (match event type / tool / summary text, optionally N matches within a time window), inactivity, stuck agent, and token threshold — each with per-(rule, session, agent) cooldown dedup. Fired alerts surface in a live feed and fan out to 14 first-class webhook providers — Slack, Discord, Teams, Google Chat, Mattermost, Rocket.Chat, Telegram, PagerDuty, Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream — plus any generic JSON endpoint (with optional HMAC-SHA256 signing and custom headers). Delivery is detached and fail-safe with a request timeout, bounded retry/backoff, secret redaction, a one-click test probe, and a per-target delivery log. Rules and channels are managed together in Settings → Alerts.":
@@ -38,8 +38,8 @@ window.__WIKI_CONTENT_I18N = {
"可从三种来源导入已有的 Claude Code 会话——重新扫描默认的 ~/.claude/projects 文件夹、扫描磁盘上的任意绝对路径,或通过 Settings → Import History 拖放 .jsonl、.zip、.tar.gz 和 .gz 归档文件。所有路径都汇入服务器用于实时 hook 的同一条摄取管道,因此导入的 token 和按模型计的成本与实时捕获完全一致。通过会话 ID 去重,重复导入是幂等的;归档解压则会防范路径遍历与 zip 炸弹膨胀。",
"The startup auto-import of ~/.claude/projects is one-time and marker-gated, so a project folder created after first launch — whose sessions never flow through hooks (for example with host-only hooks disabled) — would stay invisible until a manual rescan. A background sync closes that gap with three triggers sharing one mtime cache and a single coalesced sweep: an immediate sweep at startup, a debounced fs.watch (recursive on macOS and Windows; root plus immediate child folders on Linux to avoid the userland recursive-watcher hazard) that fires the moment a new session file or project folder appears, and a periodic safety-net poll tunable via DASHBOARD_SESSION_SYNC_MS (default 30000 ms; 0 disables the poll while leaving the watcher running). Each sweep re-parses only files whose mtime advanced and broadcasts session_created / session_updated (plus the main agent) so the UI refreshes live, while an already-imported unchanged session is skipped without re-parsing.":
"对 ~/.claude/projects 的启动时自动导入是一次性的,并由标记控制,因此首次启动之后创建的项目文件夹——其会话从不经由 hook 流入(例如禁用了仅主机的 hook)——在手动重新扫描之前都不会显示。一个后台同步通过三个共享同一个 mtime 缓存和单次合并扫描的触发器弥补这一缺口:启动时的一次立即扫描、一个去抖动的 fs.watch(在 macOS 和 Windows 上递归;在 Linux 上监听根目录加各个直接子文件夹,以规避用户态递归监听器的隐患),它会在出现新的会话文件或项目文件夹的那一刻触发,以及一个可通过 DASHBOARD_SESSION_SYNC_MS 调节的周期性安全网轮询(默认 30000 ms;0 会禁用轮询但保留监听器)。每次扫描仅重新解析 mtime 已推进的文件,并广播 session_created / session_updated(外加主 agent),从而让 UI 实时刷新;而对于已导入且未变更的会话则会被跳过,不再重新解析。",
- "Incremental JSONL reader shared across the hook handler, compaction scanner, conversation viewer, and import pipeline. Byte-offset tracking skips already-parsed content; cache hits short-circuit disk I/O so even sessions with tens of thousands of turns stay fast. It also extracts the live session title (custom-title / ai-title) so renames surface in real time.":
- "增量式 JSONL 读取器,在 hook 处理器、压缩扫描器、对话查看器和导入管道之间共享。字节偏移跟踪会跳过已解析的内容;缓存命中会短路磁盘 I/O,因此即便是有数万个回合的会话也能保持快速。它还会提取实时的会话标题(custom-title / ai-title),使重命名能实时呈现。",
+ "Incremental JSONL reader shared across the hook handler, compaction scanner, conversation viewer, and import pipeline. Byte-offset tracking skips already-parsed content; cache hits short-circuit disk I/O so even sessions with tens of thousands of turns stay fast. It also extracts the live session title (custom-title / ai-title) so renames surface in real time, plus the first user prompt as a fallback descriptor for placeholder-named sessions and agents.":
+ "增量式 JSONL 读取器,在 hook 处理器、压缩扫描器、对话查看器和导入管道之间共享。字节偏移跟踪会跳过已解析的内容;缓存命中会短路磁盘 I/O,因此即便是有数万个回合的会话也能保持快速。它还会提取实时的会话标题(custom-title / ai-title),使重命名能实时呈现,并提取首条用户 prompt,作为占位命名的会话与 agent 的后备描述符。",
"LRU eviction of cold session buffers plus a tail-cap on per-entry growable arrays (turn durations, API errors, compaction entries). A session that runs for days cannot grow a single cache entry without bound, and each entry stores its parsed result only once — no shadow copy.":
"对冷会话缓冲区进行 LRU 淘汰,并对每个条目的可增长数组(回合时长、API 错误、压缩条目)设置尾部上限。运行数天的会话也无法让任何单个缓存条目无限增长,而且每个条目只存储一次其解析结果——没有影子副本。",
"The periodic compaction sweep reads each active session's transcript path directly from sessions.transcript_path (a partial index covers exactly those rows), so the work is O(active sessions) instead of a json_extract scan over the whole events table.":
@@ -1213,8 +1213,8 @@ window.__WIKI_CONTENT_I18N = {
"Hai tab: Monitor hiển thị số liệu tổng quan, các thẻ agent đang hoạt động với cây phân cấp subagent có thể thu gọn, và một luồng hoạt động gần đây có số lượng mục lấp đầy chiều cao viewport khả dụng. Health hiển thị một vòng điểm sức khỏe hệ thống tổng hợp, biểu đồ vành khuyên về storage engine, các đồng hồ đo cache/lỗi/thành công, các thanh lời gọi công cụ, tỷ lệ hiệu quả của subagent, phân bố token theo mô hình, và số liệu thống kê nén. Cả hai tab tự động làm mới mỗi 5 giây qua WebSocket push nên chế độ xem luôn cập nhật mà không cần tải lại thủ công.",
"Toggle between Agents (Working / Waiting / Completed / Error) and Sessions (Active / Waiting / Completed / Error / Abandoned) swim lanes. A yellow Waiting column flags items sitting on the user — fresh prompt, between turns, or permission gate. Hover any column header for lifecycle tooltips explaining each state transition. Cards surface model name, cumulative cost, and the current tool being called. Counts update in real time via WebSocket so the board is always in sync with the live event store.":
"Chuyển đổi giữa các làn Agents (Working / Waiting / Completed / Error) và Sessions (Active / Waiting / Completed / Error / Abandoned). Một cột Waiting màu vàng đánh dấu các mục đang chờ người dùng — lời nhắc mới, giữa các lượt, hoặc cổng cấp quyền. Di chuột qua bất kỳ tiêu đề cột nào để xem các chú giải về vòng đời giải thích từng lần chuyển trạng thái. Các thẻ hiển thị tên mô hình, chi phí tích lũy, và công cụ đang được gọi hiện tại. Số đếm cập nhật theo thời gian thực qua WebSocket nên bảng luôn đồng bộ với kho sự kiện trực tiếp.",
- "Server-paginated table of every recorded session — each page fetches only its slice so cost computation stays bounded no matter how many sessions exist. Case-insensitive search across id, name, and cwd runs server-side with a 300 ms debounce; the status filter composes with search for precise narrowing. Each row shows the session's real name (synced live from the transcript — a /rename or claude -n title, else the auto title, with a short-ID fallback), status badge, agent count, duration, model, and estimated cost. Click any row to drill into the full session detail view with conversation transcript and agent hierarchy.":
- "Bảng phân trang phía máy chủ của mọi phiên đã ghi — mỗi trang chỉ lấy phần dữ liệu của nó nên việc tính chi phí luôn bị giới hạn bất kể có bao nhiêu phiên tồn tại. Tìm kiếm không phân biệt hoa thường trên id, name và cwd chạy ở phía máy chủ với độ trễ chống dội 300 ms; bộ lọc trạng thái kết hợp với tìm kiếm để thu hẹp chính xác. Mỗi hàng hiển thị tên thật của phiên (đồng bộ trực tiếp từ bản ghi — tiêu đề /rename hoặc claude -n, nếu không thì dùng tiêu đề tự động, với dự phòng là ID ngắn), huy hiệu trạng thái, số lượng agent, thời lượng, mô hình và chi phí ước tính. Nhấp vào bất kỳ hàng nào để đi sâu vào chế độ xem chi tiết đầy đủ của phiên với bản ghi hội thoại và phân cấp agent.",
+ "Server-paginated table of every recorded session — each page fetches only its slice so cost computation stays bounded no matter how many sessions exist. Case-insensitive search across id, name, and cwd runs server-side with a 300 ms debounce; the status filter composes with search for precise narrowing. Each row shows the session's real name (synced live from the transcript — a /rename or claude -n title, else the auto title, else the first user prompt, with a short-ID fallback), status badge, agent count, duration, model, and estimated cost. Click any row to drill into the full session detail view with conversation transcript and agent hierarchy.":
+ "Bảng phân trang phía máy chủ của mọi phiên đã ghi — mỗi trang chỉ lấy phần dữ liệu của nó nên việc tính chi phí luôn bị giới hạn bất kể có bao nhiêu phiên tồn tại. Tìm kiếm không phân biệt hoa thường trên id, name và cwd chạy ở phía máy chủ với độ trễ chống dội 300 ms; bộ lọc trạng thái kết hợp với tìm kiếm để thu hẹp chính xác. Mỗi hàng hiển thị tên thật của phiên (đồng bộ trực tiếp từ bản ghi — tiêu đề /rename hoặc claude -n, nếu không thì dùng tiêu đề tự động, nếu vẫn không có thì prompt đầu tiên của người dùng, với dự phòng là ID ngắn), huy hiệu trạng thái, số lượng agent, thời lượng, mô hình và chi phí ước tính. Nhấp vào bất kỳ hàng nào để đi sâu vào chế độ xem chi tiết đầy đủ của phiên với bản ghi hội thoại và phân cấp agent.",
"Per-session deep dive with a collapsible agent hierarchy tree and a full chronological event timeline showing every tool call name and summary. An overview panel at the top surfaces tile counters for events, tool calls, subagents, compactions, errors, and duration. Top-tool usage bars and a subagent type breakdown give quick distribution reads. The conversation viewer renders markdown with syntax highlighting, per-tool styled blocks, slash-command pills with their captured TUI output, and inline session-rename markers. Export the entire session as JSON or share the permalink for async review.":
"Phân tích sâu theo từng phiên với một cây phân cấp agent có thể thu gọn và một dòng thời gian sự kiện đầy đủ theo trình tự thời gian, hiển thị tên và bản tóm tắt của mỗi lần gọi công cụ. Một bảng tổng quan ở trên cùng hiển thị các bộ đếm dạng ô cho sự kiện, lời gọi công cụ, subagent, lần nén, lỗi và thời lượng. Các thanh sử dụng công cụ hàng đầu và bảng phân tích theo loại subagent cho phép đọc nhanh phân bố. Trình xem hội thoại hiển thị markdown với tô sáng cú pháp, các khối có kiểu dáng riêng theo từng công cụ, pill lệnh gạch chéo kèm output TUI đã ghi lại, và chỉ báo đổi tên phiên nội tuyến. Xuất toàn bộ phiên dưới dạng JSON hoặc chia sẻ liên kết cố định để xem xét bất đồng bộ.",
"A rules-based alerting engine evaluates the live event stream server-side: event pattern (match event type / tool / summary text, optionally N matches within a time window), inactivity, stuck agent, and token threshold — each with per-(rule, session, agent) cooldown dedup. Fired alerts surface in a live feed and fan out to 14 first-class webhook providers — Slack, Discord, Teams, Google Chat, Mattermost, Rocket.Chat, Telegram, PagerDuty, Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream — plus any generic JSON endpoint (with optional HMAC-SHA256 signing and custom headers). Delivery is detached and fail-safe with a request timeout, bounded retry/backoff, secret redaction, a one-click test probe, and a per-target delivery log. Rules and channels are managed together in Settings → Alerts.":
@@ -1233,8 +1233,8 @@ window.__WIKI_CONTENT_I18N = {
"Nhập các phiên Claude Code hiện có từ ba nguồn — quét lại thư mục mặc định ~/.claude/projects, quét bất kỳ đường dẫn tuyệt đối nào trên đĩa, hoặc kéo-thả các tệp lưu trữ .jsonl, .zip, .tar.gz và .gz qua Settings → Import History. Tất cả các đường dẫn đều đổ vào cùng một đường ống nạp mà máy chủ dùng cho các hook trực tiếp, nên token đã nhập và chi phí theo từng model khớp chính xác với việc thu thập theo thời gian thực. Việc nhập lại là bất biến nhờ khử trùng lặp theo session-ID, và quá trình giải nén được bảo vệ chống path traversal cùng sự bùng nổ kiểu zip-bomb.",
"The startup auto-import of ~/.claude/projects is one-time and marker-gated, so a project folder created after first launch — whose sessions never flow through hooks (for example with host-only hooks disabled) — would stay invisible until a manual rescan. A background sync closes that gap with three triggers sharing one mtime cache and a single coalesced sweep: an immediate sweep at startup, a debounced fs.watch (recursive on macOS and Windows; root plus immediate child folders on Linux to avoid the userland recursive-watcher hazard) that fires the moment a new session file or project folder appears, and a periodic safety-net poll tunable via DASHBOARD_SESSION_SYNC_MS (default 30000 ms; 0 disables the poll while leaving the watcher running). Each sweep re-parses only files whose mtime advanced and broadcasts session_created / session_updated (plus the main agent) so the UI refreshes live, while an already-imported unchanged session is skipped without re-parsing.":
"Việc tự động nhập ~/.claude/projects lúc khởi động chỉ chạy một lần và được kiểm soát bằng marker, nên một thư mục dự án được tạo sau lần khởi chạy đầu tiên — mà các phiên của nó không bao giờ chảy qua hook (ví dụ khi đã tắt hook chỉ-dành-cho-máy-chủ) — sẽ không hiển thị cho tới khi quét lại thủ công. Một tiến trình đồng bộ nền lấp khoảng trống đó bằng ba trình kích hoạt cùng dùng chung một bộ nhớ đệm mtime và một lượt quét gộp duy nhất: một lượt quét tức thì lúc khởi động, một fs.watch có khử dội (đệ quy trên macOS và Windows; theo dõi thư mục gốc cộng từng thư mục con trực tiếp trên Linux để tránh hiểm họa của trình theo dõi đệ quy ở tầng người dùng) kích hoạt ngay khoảnh khắc một tệp phiên hoặc thư mục dự án mới xuất hiện, và một lượt thăm dò an toàn định kỳ có thể điều chỉnh qua DASHBOARD_SESSION_SYNC_MS (mặc định 30000 ms; 0 tắt thăm dò nhưng vẫn giữ trình theo dõi chạy). Mỗi lượt quét chỉ phân tích lại những tệp có mtime tiến lên và phát đi session_created / session_updated (cùng với agent chính) để UI làm mới trực tiếp, trong khi một phiên đã nhập và không thay đổi thì được bỏ qua mà không phân tích lại.",
- "Incremental JSONL reader shared across the hook handler, compaction scanner, conversation viewer, and import pipeline. Byte-offset tracking skips already-parsed content; cache hits short-circuit disk I/O so even sessions with tens of thousands of turns stay fast. It also extracts the live session title (custom-title / ai-title) so renames surface in real time.":
- "Trình đọc JSONL tăng dần được chia sẻ giữa trình xử lý hook, trình quét nén, trình xem hội thoại và đường ống nhập. Việc theo dõi byte-offset bỏ qua nội dung đã phân tích; các lần trúng cache rút ngắn I/O đĩa nên ngay cả những phiên có hàng chục nghìn lượt vẫn chạy nhanh. Nó cũng trích xuất tiêu đề phiên trực tiếp (custom-title / ai-title) để việc đổi tên hiển thị theo thời gian thực.",
+ "Incremental JSONL reader shared across the hook handler, compaction scanner, conversation viewer, and import pipeline. Byte-offset tracking skips already-parsed content; cache hits short-circuit disk I/O so even sessions with tens of thousands of turns stay fast. It also extracts the live session title (custom-title / ai-title) so renames surface in real time, plus the first user prompt as a fallback descriptor for placeholder-named sessions and agents.":
+ "Trình đọc JSONL tăng dần được chia sẻ giữa trình xử lý hook, trình quét nén, trình xem hội thoại và đường ống nhập. Việc theo dõi byte-offset bỏ qua nội dung đã phân tích; các lần trúng cache rút ngắn I/O đĩa nên ngay cả những phiên có hàng chục nghìn lượt vẫn chạy nhanh. Nó cũng trích xuất tiêu đề phiên trực tiếp (custom-title / ai-title) để việc đổi tên hiển thị theo thời gian thực, cùng prompt đầu tiên của người dùng làm mô tả dự phòng cho các phiên và agent còn mang tên placeholder.",
"LRU eviction of cold session buffers plus a tail-cap on per-entry growable arrays (turn durations, API errors, compaction entries). A session that runs for days cannot grow a single cache entry without bound, and each entry stores its parsed result only once — no shadow copy.":
"Loại bỏ theo LRU các bộ đệm phiên nguội cùng với một giới hạn đuôi trên các mảng có thể tăng trưởng theo từng mục (thời lượng lượt, lỗi API, mục nén). Một phiên chạy nhiều ngày cũng không thể làm một mục cache đơn lẻ tăng không giới hạn, và mỗi mục chỉ lưu kết quả đã phân tích của nó một lần — không có bản sao bóng.",
"The periodic compaction sweep reads each active session's transcript path directly from sessions.transcript_path (a partial index covers exactly those rows), so the work is O(active sessions) instead of a json_extract scan over the whole events table.":
diff --git a/wiki/index.html b/wiki/index.html
index 3024ff96..84aec91a 100644
--- a/wiki/index.html
+++ b/wiki/index.html
@@ -923,8 +923,9 @@ Sessions Table
name, and cwd runs server-side with a 300 ms
debounce; the status filter composes with search for precise narrowing.
Each row shows the session's real name (synced live from the transcript — a
- /rename or claude -n title, else the auto title, with
- a short-ID fallback), status badge, agent count, duration, model, and estimated
+ /rename or claude -n title, else the auto title,
+ else the first user prompt, with a short-ID fallback), status badge, agent
+ count, duration, model, and estimated
cost. Click any row to drill into the full session detail view with conversation
transcript and agent hierarchy.
@@ -1076,7 +1077,8 @@ Transcript Cache
already-parsed content; cache hits short-circuit disk I/O so even sessions
with tens of thousands of turns stay fast. It also extracts the live session
title (custom-title / ai-title) so renames surface in
- real time.
+ real time, plus the first user prompt as a fallback descriptor for
+ placeholder-named sessions and agents.
@@ -6073,7 +6075,7 @@
Technology Choices
-
+