feat(vis): full session debugging — tasks/cron, execution timeline, retries & tool progress (#1210)

* feat(vis): surface background tasks and cron jobs

The visualizer read every wire/state/blob artifact a session persists but
ignored the two on-demand families agent-core also writes under the session
directory: background tasks (tasks/<id>.json + output.log) and cron jobs
(cron/<id>.json). Neither is reconstructable from the wire, so there was no
way to inspect what a session spawned in the background or scheduled.

Server:
- task-store / cron-store read-only readers mirroring agent-core's on-disk
  layout, id-validation guard, and legacy snake_case task normalization
- GET /:id/tasks, /:id/tasks/:taskId/output (byte-window paged via an exact
  nextOffset cursor), and /:id/cron routes
- re-export the public background-task types from agent-core; mirror the
  non-exported CronTask shape with a fixture-backed drift test

Web:
- Tasks tab: process/agent/question kinds with status, timing, kind-specific
  fields, raw JSON, and a progressively paged output.log viewer
- Cron tab: expression, prompt, recurring/one-shot, created/last-fired
- count badges on both tabs

Tests: +20 (lib + route), all 113 vis-server tests green; web typecheck and
build clean.

* feat(agent-core): persist step retries and tool progress summary

Two transient signals were only ever emitted as live-only loop events, so
nothing survived in the agent record for post-hoc analysis:

- step retries: chatWithRetry gains an onRetry callback; turn-step collects
  the recovered attempts and attaches them to step.end as an optional
  `retries` array (previously only the live `step.retrying` event).
- tool progress: tool-call distills a tool's sparse status/percent updates
  into a bounded `progress` summary (updateCount / lastStatus / maxPercent)
  on tool.result. Streamed stdout/stderr is excluded — it would bloat the
  wire and is already reflected in the result output.

Both are additive optional fields, so the wire protocol version is unchanged
and existing records keep loading. New public types: LoopStepRetryRecord,
LoopToolProgressSummary.

* feat(vis): add execution-analysis timeline and surface retries/progress

Turn the debugger from a flat record viewer into an analysis tool.

New Timeline tab: folds the wire into turns → steps → tool calls (client-side,
no extra round-trip) and derives the metrics the raw list hides — per-turn /
per-step / per-tool duration, per-turn token cost, a context-window fill
sparkline with cache-hit rate, a tool usage table, idle-gap detection, and a
config-change timeline.

Inline elsewhere:
- Wire rows show tool.call → tool.result elapsed time; tool.result detail
  shows truncation, output size, retries, and the progress summary.
- Issues drawer gains tool-error, truncation, filtered, max_tokens, and
  retried categories.
- Tasks tab links agent-kind tasks to the subagent's wire.

Wires up vitest for the web package and adds analysis/issues unit tests.

* feat(vis): import debug zips with a logs view and imported-session filtering

A `/export-debug-zip` bundle is just `manifest.json` plus a flattened session
directory, which vis already knows how to read. Importing one therefore lights
up every existing tab for a session that lives on someone else's machine.

Server:
- zip-import: yauzl extraction with zip-slip path guards and entry-count /
  uncompressed-size caps for untrusted uploads.
- import-store: extract a bundle into <home>/imported/<imp_…>/, validate it
  has a main wire, and record an import-meta.json sidecar.
- session-store resolves imp_-prefixed ids against imported/, so wire /
  context / tasks / cron / blobs / logs all work on imported sessions; agent
  homedirs are re-derived locally (the bundle holds foreign absolute paths).
- POST /api/imports (raw zip body) and GET /api/sessions/:id/logs (structured
  log lines — also available for local sessions).

Web:
- session rail: import button + all/local/imported filter + imported badge.
- new Logs tab: virtualized, level filter, search, session/global toggle.
- manifest card atop the State tab for imported sessions.

SessionSummary/SessionDetail gain `imported` + `importMeta`. Tests cover
extraction, the zip-slip guard, list merge, reading an imported wire through
the existing route, and log parsing.

* fix(vis): read tasks/cron from agent homedirs and stop persisting tool status text

Addresses review feedback on the debug-tooling changes:

- Background tasks and cron jobs are persisted under each agent's homedir
  (<session>/agents/<id>/tasks and /cron), not the session root. The Tasks and
  Cron tabs read the session root, so they showed nothing for normal sessions.
  Both routes now aggregate across detail.agents homedirs; task entries carry
  the owning agentId. The route-test fixtures were writing to the wrong
  (session-root) location too — corrected to the real agents/main layout so
  they actually exercise the path.

- tool.result progress no longer keeps free-form status text, only updateCount
  and maxPercent. A tool's status string can contain sensitive data (e.g. an
  MCP OAuth authorization URL) that must not leak into persisted wire files or
  exported debug bundles.

* fix(vis): stop the main content area from overflowing horizontally

The <main> flex child lacked min-w-0, so it defaulted to min-width:auto and
refused to shrink below its content's intrinsic width. Tabs that lay out in
normal flow with flex-wrap rows (the Timeline tab) then got unbounded width,
never wrapped, and blew the layout out to thousands of pixels wide. Adding
min-w-0 lets the column shrink to the available width so its content wraps,
truncates, or scrolls within its own container.

* fix(vis): resolve local global log path and imported-state agent fallback

- Logs tab: for non-imported sessions the shared global log lives at
  <KIMI_CODE_HOME>/logs/kimi-code.log, not under the session dir (that path is
  only used inside exported bundles). The route now reads the home path for
  local sessions, so the global-log toggle works for them.
- Imported detail: a bundle's state.json is best-effort and may omit the
  agents map. When the inventory is empty, fall back to discovering agents
  from disk so routes that require an agent (wire/context) still resolve main.

* fix(vis): harden imported manifest and task parsing against corrupt input

An imported debug zip is untrusted, so a syntactically valid but type-corrupt
file could crash whole views:

- manifest.json: a non-string field (e.g. workspaceDir: 123) flowed into
  SessionSummary.workDir, where the session rail calls .split('/') and crashed
  the entire list. readManifest/readImportMeta now sanitize declared string
  fields, keeping only strings.
- task JSON: a record that passed the shape guard but held a non-string legacy
  field (e.g. stop_reason: 5) threw in normalization, failing GET /tasks with a
  500 and hiding all of a session's tasks. optionalNonEmptyString now tolerates
  non-strings, and listBackgroundTasks skips any record that still fails to
  normalize — honouring the reader's documented silently-skips contract.

* fix(vis): discover rotated logs; keep tool progress on thrown failures

- Logs tab: the diagnostic log can rotate (kimi-code.log.1, .2, …) and an
  exported bundle may contain only the archives. The route now discovers the
  active file plus its rotated siblings and concatenates them oldest-first, so
  a rotated-away log still surfaces (covered by node-sdk's rotated-export case).
- agent-core: a tool that reported sparse progress and then threw lost its
  progress summary, because the catch path built the error tool.result without
  it. Thread progressSummary through that path too, matching the success and
  malformed-return paths.

* fix(vis): skip type-corrupt agent entries in imported state

readImportedDetail's empty-inventory fallback never ran when a bundle's
state.json had a non-empty but type-corrupt agents map (e.g.
`{ "agents": { "main": null } }`): inventoryAgents dereferenced the null entry
and threw, so readSessionDetail returned 500 instead of recovering main from
the on-disk agents/main/wire.jsonl. inventoryAgents now skips non-object
entries, letting the disk-discovery fallback take over.

* fix(vis): reset timeline agent on session change; preserve context on zero-usage steps

- Timeline tab kept the previously-selected agent id across session navigation,
  so a subagent selection would 404 against the next session. Reset it to main
  on sessionId change, mirroring WireTab/ContextTab.
- A zero-usage step.end (e.g. a content-filtered response) reset the
  context-window fill to 0, pushing a false drop into the Timeline chart and the
  Context tab. agent-core's ContextMemory keeps the prior count in that case;
  the analysis lib and the context projector now do the same.

* revert: drop agent-core retries/tool-progress persistence

These were the only changes in this branch that touched agent-core. They
persisted two previously live-only signals (step retries, tool progress) to
the wire purely so the visualizer could display them — marginal features that
did not justify modifying the core loop or extending the wire surface.

Reverts the agent-core loop/type/export changes (restored to main, keeping
#1209) and its changeset, and removes the vis-side rendering and types that
consumed step.end.retries / tool.result.progress. The rest of vis is unchanged
and reads only data agent-core already persists.
This commit is contained in:
Kai 2026-06-30 13:40:37 +08:00 committed by GitHub
parent b41f108584
commit 525fb146d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3926 additions and 53 deletions

View file

@ -34,10 +34,14 @@
"@hono/node-server": "^1.13.7",
"@moonshot-ai/agent-core": "workspace:^",
"@moonshot-ai/kosong": "workspace:^",
"hono": "^4.7.7"
"hono": "^4.7.7",
"yauzl": "^3.3.0"
},
"devDependencies": {
"@types/yauzl": "^2.10.3",
"@types/yazl": "^2.4.6",
"tsx": "^4.21.0",
"vitest": "4.1.4"
"vitest": "4.1.4",
"yazl": "^3.3.1"
}
}

View file

@ -8,9 +8,13 @@ import { KIMI_CODE_HOME } from './config';
import { serveWebAsset, type WebAsset } from './lib/web-asset';
import { blobsRoute } from './routes/blobs';
import { contextRoute } from './routes/context';
import { cronRoute } from './routes/cron';
import { importsRoute } from './routes/imports';
import { logsRoute } from './routes/logs';
import { sessionDetailRoute } from './routes/session-detail';
import { sessionsRoute } from './routes/sessions';
import { subagentsRoute } from './routes/subagents';
import { tasksRoute } from './routes/tasks';
import { wireRoute } from './routes/wire';
/** Resolve the SPA bundle directory next to the compiled server.mjs, if it
@ -96,6 +100,10 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Hono> {
api.route('/sessions', wireRoute(home));
api.route('/sessions', subagentsRoute(home));
api.route('/sessions', blobsRoute(home));
api.route('/sessions', tasksRoute(home));
api.route('/sessions', cronRoute(home));
api.route('/sessions', logsRoute(home));
api.route('/imports', importsRoute(home));
// Mount contextRoute last because it currently uses a catch-all stub
// (Phase C scope) that would otherwise shadow more specific routes
// registered below it.

View file

@ -16,14 +16,74 @@ export type {
LoopRecordedEvent,
ContextMessage,
PromptOrigin,
// Background-task shapes are part of agent-core's public surface, so the
// visualizer tracks them directly instead of duplicating the union.
BackgroundTaskInfo,
BackgroundTaskStatus,
ProcessBackgroundTaskInfo,
AgentBackgroundTaskInfo,
QuestionBackgroundTaskInfo,
} from '@moonshot-ai/agent-core';
export { AGENT_WIRE_PROTOCOL_VERSION } from '@moonshot-ai/agent-core';
export type { Message, ContentPart, ToolCall, TokenUsage } from '@moonshot-ai/kosong';
// Local binding for the `AgentRecord` type used by the vis-only DTOs below
// (e.g. `WireEntry.data`). The `export type { … }` re-export above forwards
// the name to consumers but does NOT bring it into this module's scope.
import type { AgentRecord } from '@moonshot-ai/agent-core';
// Local bindings for the upstream types referenced by the vis-only DTOs
// below. The `export type { … }` re-export above forwards the names to
// consumers but does NOT bring them into this module's scope.
import type { AgentRecord, BackgroundTaskInfo } from '@moonshot-ai/agent-core';
/**
* Persistent representation of a cron task.
*
* Structural mirror of agent-core's `CronTask` (`tools/cron/types.ts`),
* which is NOT re-exported from the package entry point. The shape is
* tiny and frozen; `cron-store.test.ts` reads a fixture written in the
* real on-disk format so the mirror cannot silently drift from disk.
*/
export interface CronTask {
readonly id: string;
readonly cron: string;
readonly prompt: string;
readonly createdAt: number;
readonly recurring?: boolean;
readonly lastFiredAt?: number;
}
/**
* `manifest.json` shape inside a `/export-debug-zip` bundle. Structural
* mirror of agent-core's `ExportSessionManifest` (`rpc/core-api.ts`), which
* is not re-exported from the package entry. All fields optional-tolerant
* because the manifest comes from another machine / kimi-code version.
*/
export interface ImportManifest {
sessionId?: string;
exportedAt?: string;
kimiCodeVersion?: string;
wireProtocolVersion?: string;
os?: string;
nodejsVersion?: string;
sessionFirstActivity?: string;
sessionLastActivity?: string;
title?: string;
workspaceDir?: string;
sessionLogPath?: string;
globalLogPath?: string;
installSource?: string;
shellEnv?: unknown;
}
/** vis-side bookkeeping for one imported bundle, written to
* `imported/<importId>/import-meta.json`. */
export interface ImportInfo {
/** vis-generated id (`imp_…`); also the session id the UI addresses. */
importId: string;
/** ISO time the zip was imported into vis. */
importedAt: string;
/** Original uploaded file name, when known. */
originalName: string | null;
/** Parsed `manifest.json`, when present and readable. */
manifest: ImportManifest | null;
}
// ── vis-only DTOs ──────────────────────────────────────────────────────────
@ -58,6 +118,10 @@ export interface SessionSummary {
mainWireRecordCount: number;
wireProtocolVersion: string | null;
health: SessionHealth;
/** True for sessions imported from a debug zip (under `<home>/imported/`). */
imported: boolean;
/** Export/import provenance for imported sessions; null for local ones. */
importMeta: ImportInfo | null;
}
export interface AgentInfo {
@ -84,6 +148,10 @@ export interface SessionDetail {
workDir: string;
state: unknown; // 原样透传,前端按 state.json 真实形状渲染
agents: AgentInfo[];
/** True for sessions imported from a debug zip. */
imported: boolean;
/** Export/import provenance for imported sessions; null for local ones. */
importMeta: ImportInfo | null;
}
/** One line of `wire.jsonl` after vis has parsed (and possibly migrated)
@ -122,3 +190,84 @@ export interface AgentTreeResponse {
sessionId: string;
tree: AgentNode[];
}
// ── background tasks & cron ─────────────────────────────────────────────────
/** A persisted background task plus vis-derived `output.log` metadata.
* `task` is the normalized agent-core shape; the size/exists fields let the
* UI badge how much output a task produced and offer a "view log" affordance
* without first fetching the (potentially large) log body. */
export interface BackgroundTaskEntry {
task: BackgroundTaskInfo;
/** Which agent persisted this task tasks live under the spawning agent's
* homedir (`<session>/agents/<agentId>/tasks`), not the session root. */
agentId: string;
/** Total byte size of the task's `output.log` (0 when absent). */
outputSizeBytes: number;
/** Whether an `output.log` file exists for this task. */
outputExists: boolean;
}
export interface BackgroundTasksResponse {
sessionId: string;
tasks: BackgroundTaskEntry[];
}
/** One byte-window of a task's `output.log`. Byte-level (not line-level)
* paging mirrors how the log is stored on disk, so arbitrarily large logs
* can be paged without loading the whole file. */
export interface TaskOutputResponse {
sessionId: string;
taskId: string;
/** Byte offset this window starts at. */
offset: number;
/** Byte offset immediately after this window; pass as the next `offset`
* to page forward without drift. */
nextOffset: number;
/** Total byte size of the log on disk. */
size: number;
/** UTF-8 decoded window content. */
content: string;
/** True when this window reaches the end of the log. */
eof: boolean;
}
export interface CronTasksResponse {
sessionId: string;
cron: CronTask[];
}
// ── imported sessions & logs ────────────────────────────────────────────────
/** Result of importing a debug zip. */
export interface ImportResult {
/** The `imp_…` id the UI uses to address the imported session. */
sessionId: string;
importMeta: ImportInfo;
}
/** One parsed line of a diagnostic log. */
export interface LogLine {
/** 1-indexed line number in the source log. */
lineNo: number;
/** ISO timestamp parsed from the line prefix, or null if unparseable. */
time: string | null;
/** Log level (INFO / WARN / ERROR / DEBUG / …), uppercased, or null. */
level: string | null;
/** The human message between the level and the structured fields. */
message: string;
/** Parsed trailing `key=value` fields. */
fields: Record<string, string>;
/** The original line, verbatim. */
raw: string;
}
export interface LogsResponse {
sessionId: string;
which: 'session' | 'global';
/** Which logs exist on disk for this session. */
available: { session: boolean; global: boolean };
lines: LogLine[];
/** True when the log was longer than the served cap and got truncated. */
truncated: boolean;
}

View file

@ -171,12 +171,16 @@ export function projectContext(
// Absolute context-window fill, mirroring agent-core
// ContextMemory._tokenCount: the latest step.end usage REPLACES the
// snapshot (it is not cumulative — see Task P1.7 note on byScope).
// A zero-usage step.end (e.g. a content-filtered response) is the one
// exception agent-core makes — it keeps the prior count instead of
// resetting to 0 — so guard against a false drop here too.
if ('usage' in ev && ev.usage !== undefined) {
contextTokens =
const fill =
ev.usage.inputCacheRead +
ev.usage.inputCacheCreation +
ev.usage.inputOther +
ev.usage.output;
if (fill > 0) contextTokens = fill;
}
openSteps.delete(ev.uuid);
} else if (ev.type === 'tool.result') {

View file

@ -0,0 +1,67 @@
// apps/vis/server/src/lib/cron-store.ts
//
// Read-only reader for cron tasks, persisted by agent-core under each (non-sub)
// agent's homedir at `<agentDir>/cron/<id>.json` (callers pass the agent
// homedir, `<session>/agents/<id>`). The visualizer never writes these files;
// it mirrors agent-core's on-disk layout (tools/cron/persist.ts) for reading.
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { CronTask } from './agent-record-types';
/** Cron id format: 8 lowercase hex chars (mirror of agent-core's cron-id
* shape). Enforced before joining a path so a stray / hand-edited filename
* cannot escape the cron directory. */
const VALID_CRON_ID = /^[0-9a-f]{8}$/;
export function isSafeCronId(id: string): boolean {
return VALID_CRON_ID.test(id);
}
function cronDirOf(agentDir: string): string {
return join(agentDir, 'cron');
}
/**
* Enumerate all persisted cron tasks for a session, sorted by creation time
* (oldest first, matching how a user scheduled them).
*
* Silently skips filenames that don't match `VALID_CRON_ID`, files that fail
* to read/parse, and records missing the required cron fields.
*/
export async function listCronTasks(agentDir: string): Promise<CronTask[]> {
const dir = cronDirOf(agentDir);
let entries: import('node:fs').Dirent[];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const out: CronTask[] = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const id = entry.name.slice(0, -'.json'.length);
if (!VALID_CRON_ID.test(id)) continue;
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(join(dir, entry.name), 'utf8'));
} catch {
continue;
}
if (isCronTask(parsed)) out.push(parsed);
}
out.sort((a, b) => a.createdAt - b.createdAt);
return out;
}
function isCronTask(value: unknown): value is CronTask {
if (typeof value !== 'object' || value === null) return false;
const o = value as Record<string, unknown>;
return (
typeof o['id'] === 'string' &&
typeof o['cron'] === 'string' &&
typeof o['prompt'] === 'string' &&
typeof o['createdAt'] === 'number'
);
}

View file

@ -0,0 +1,167 @@
// apps/vis/server/src/lib/import-store.ts
//
// Imported debug bundles (`/export-debug-zip` zips) live under
// `<home>/imported/<importId>/`, unzipped to the same on-disk shape as a real
// session directory (state.json, agents/<id>/wire.jsonl, tasks/, cron/, logs/)
// plus the bundle's `manifest.json`. Because the layout matches a session
// directory, every existing read path (wire / context / tasks / cron / blobs /
// logs) works against an imported session once `session-store` resolves its
// directory — see `findSessionDir`.
//
// This module owns ONLY: id allocation, safe extraction, validation, the
// vis-side `import-meta.json` sidecar, enumeration, and deletion. Summary /
// detail construction stays in `session-store` so imported and local sessions
// share one code path.
import { randomBytes } from 'node:crypto';
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { ImportInfo, ImportManifest } from './agent-record-types';
import { extractZip, ZipImportError } from './zip-import';
const IMPORT_ID_RE = /^imp_[0-9a-f]{12}$/;
const META_FILE = 'import-meta.json';
export function isImportId(id: string): boolean {
return IMPORT_ID_RE.test(id);
}
export function importedRootOf(home: string): string {
return join(home, 'imported');
}
export function importedDirOf(home: string, importId: string): string {
if (!isImportId(importId)) throw new ZipImportError(`invalid import id: "${importId}"`);
return join(importedRootOf(home), importId);
}
function newImportId(): string {
return `imp_${randomBytes(6).toString('hex')}`;
}
/**
* Extract a debug zip into a fresh `imported/<id>/` directory and validate it
* looks like a session bundle. On any failure the partial directory is removed
* so a bad upload never lingers in the imported list. Returns the bundle's
* `import-meta.json` contents.
*/
export async function importSessionZip(
home: string,
zipBuffer: Buffer,
originalName: string | null,
now: Date,
): Promise<ImportInfo> {
const importId = newImportId();
const dir = importedDirOf(home, importId);
await mkdir(dir, { recursive: true });
try {
await extractZip(zipBuffer, dir);
// A debug bundle must contain a main wire; without it there is nothing to
// visualize. `state.json` / `manifest.json` are best-effort.
const hasMainWire = await pathExists(join(dir, 'agents', 'main', 'wire.jsonl'));
if (!hasMainWire) {
throw new ZipImportError(
'zip does not look like a kimi-code session bundle (missing agents/main/wire.jsonl)',
);
}
const manifest = await readManifest(dir);
const meta: ImportInfo = {
importId,
importedAt: now.toISOString(),
originalName: originalName !== null && originalName.length > 0 ? originalName : null,
manifest,
};
await writeFile(join(dir, META_FILE), JSON.stringify(meta, null, 2), 'utf8');
return meta;
} catch (error) {
await rm(dir, { recursive: true, force: true }).catch(() => {});
throw error instanceof ZipImportError ? error : new ZipImportError((error as Error).message);
}
}
/** Enumerate imported bundle ids (newest-first by directory mtime). */
export async function listImportedIds(home: string): Promise<string[]> {
const root = importedRootOf(home);
let entries: import('node:fs').Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const ids = entries
.filter((e) => e.isDirectory() && isImportId(e.name))
.map((e) => e.name);
const withMtime = await Promise.all(
ids.map(async (id) => {
const mtime = await stat(join(root, id)).then((s) => s.mtimeMs).catch(() => 0);
return { id, mtime };
}),
);
return withMtime.toSorted((a, b) => b.mtime - a.mtime).map((x) => x.id);
}
export async function readImportMeta(home: string, importId: string): Promise<ImportInfo | null> {
try {
const raw = await readFile(join(importedDirOf(home, importId), META_FILE), 'utf8');
const meta = JSON.parse(raw) as ImportInfo;
// The sidecar is vis-written, but re-sanitize the manifest in case the
// imported directory was hand-edited, so a corrupt type cannot reach the
// session list and crash the UI.
return { ...meta, manifest: meta.manifest ? sanitizeManifest(meta.manifest) : null };
} catch {
return null;
}
}
export async function deleteImported(home: string, importId: string): Promise<boolean> {
if (!isImportId(importId)) return false;
const dir = importedDirOf(home, importId);
if (!(await pathExists(dir))) return false;
await rm(dir, { recursive: true, force: true });
return true;
}
async function readManifest(dir: string): Promise<ImportManifest | null> {
try {
return sanitizeManifest(JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8')));
} catch {
return null;
}
}
/** Declared string fields of {@link ImportManifest}. `shellEnv` is free-form. */
const MANIFEST_STRING_FIELDS = [
'sessionId', 'exportedAt', 'kimiCodeVersion', 'wireProtocolVersion', 'os',
'nodejsVersion', 'sessionFirstActivity', 'sessionLastActivity', 'title',
'workspaceDir', 'sessionLogPath', 'globalLogPath', 'installSource',
] as const;
/**
* Coerce an untrusted manifest object so every declared string field is either
* a string or absent. A type-corrupt bundle (e.g. `{ "workspaceDir": 123 }`)
* would otherwise propagate a non-string into `SessionSummary.workDir`, where
* the session rail calls `.split('/')` and crashes the whole list.
*/
function sanitizeManifest(raw: unknown): ImportManifest | null {
if (typeof raw !== 'object' || raw === null) return null;
const o = raw as Record<string, unknown>;
const m: Record<string, unknown> = {};
for (const field of MANIFEST_STRING_FIELDS) {
if (typeof o[field] === 'string') m[field] = o[field];
}
if (o['shellEnv'] !== undefined) m['shellEnv'] = o['shellEnv'];
return m as ImportManifest;
}
async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,122 @@
// apps/vis/server/src/lib/log-reader.ts
//
// Parse a kimi-code diagnostic log into structured lines for the Logs view.
//
// Lines look like:
// 2026-06-15T05:32:08.722Z INFO llm config turnStep=0.1 provider=openai …
// i.e. `<ISO time> <LEVEL> <message> <key=value …>`. Anything that does not
// match (continuation lines, stack traces) is kept verbatim as a level-less,
// time-less message so nothing is dropped.
import { readdir, readFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import type { LogLine } from './agent-record-types';
/** Cap served lines so a multi-hundred-MB log cannot blow up the response.
* When exceeded we keep the TAIL (most recent), where failures usually are. */
const MAX_LINES = 20_000;
const LINE_RE = /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s+([A-Za-z]+)\s+(.*)$/;
const FIELD_START_RE = /(^|\s)[A-Za-z_][\w.-]*=/;
const FIELD_RE = /([A-Za-z_][\w.-]*)=(\S+)/g;
export interface LogReadResult {
lines: LogLine[];
truncated: boolean;
}
/**
* Discover a base log file plus its rotated siblings (`<base>`, `<base>.1`,
* `<base>.2`, ) in chronological order, oldest first.
*
* agent-core rotates by renaming the active file to `.1` and bumping older
* archives to higher numbers (`sinks.ts` rotate()), so the un-suffixed file is
* newest and `.N` is oldest. A bundle whose active log has already rotated
* away may contain only `<base>.1`, etc. which the Logs tab must still find.
*/
export async function discoverLogFiles(baseLogPath: string): Promise<string[]> {
const dir = dirname(baseLogPath);
const base = basename(baseLogPath);
let names: string[];
try {
names = (await readdir(dir, { withFileTypes: true }))
.filter((e) => e.isFile())
.map((e) => e.name);
} catch {
return [];
}
let hasActive = false;
const rotated: { n: number; name: string }[] = [];
const prefix = `${base}.`;
for (const name of names) {
if (name === base) {
hasActive = true;
continue;
}
if (name.startsWith(prefix)) {
const suffix = name.slice(prefix.length);
if (/^\d+$/.test(suffix)) rotated.push({ n: Number(suffix), name });
}
}
rotated.sort((a, b) => b.n - a.n); // highest index == oldest → first
const ordered = rotated.map((r) => join(dir, r.name));
if (hasActive) ordered.push(join(dir, base)); // active is newest → last
return ordered;
}
/**
* Read and parse the given log files in order, concatenated into one structured
* stream with continuous line numbers. Returns null when none could be read.
* The MAX_LINES tail cap applies across the combined set.
*/
export async function readLogs(
paths: readonly string[],
maxLines = MAX_LINES,
): Promise<LogReadResult | null> {
const allLines: string[] = [];
let read = 0;
for (const path of paths) {
let raw: string;
try {
raw = await readFile(path, 'utf8');
} catch {
continue;
}
read += 1;
const lines = raw.split(/\r?\n/);
// Drop a single trailing empty line from each file's final newline.
if (lines.length > 0 && lines.at(-1) === '') lines.pop();
for (const line of lines) allLines.push(line);
}
if (read === 0) return null;
const truncated = allLines.length > maxLines;
const startLineNo = truncated ? allLines.length - maxLines : 0;
const slice = truncated ? allLines.slice(startLineNo) : allLines;
const lines: LogLine[] = slice.map((text, i) => parseLogLine(text, startLineNo + i + 1));
return { lines, truncated };
}
export function parseLogLine(raw: string, lineNo: number): LogLine {
const m = LINE_RE.exec(raw);
if (m === null) {
return { lineNo, time: null, level: null, message: raw, fields: {}, raw };
}
const time = m[1]!;
const level = m[2]!.toUpperCase();
const rest = m[3]!;
const fields: Record<string, string> = {};
let message = rest;
const fieldStart = rest.search(FIELD_START_RE);
if (fieldStart >= 0) {
message = rest.slice(0, fieldStart).trim();
const fieldsPart = rest.slice(fieldStart);
for (const fm of fieldsPart.matchAll(FIELD_RE)) {
fields[fm[1]!] = fm[2]!;
}
}
return { lineNo, time, level, message: message.trim(), fields, raw };
}

View file

@ -3,8 +3,9 @@ import { readdir, readFile, stat } from 'node:fs/promises';
import { join, resolve, sep } from 'node:path';
import { createInterface } from 'node:readline';
import type { SessionSummary, SessionDetail, AgentInfo, SessionHealth } from './agent-record-types';
import type { SessionSummary, SessionDetail, AgentInfo, SessionHealth, ImportInfo } from './agent-record-types';
import { compareAgentIds } from './agent-tree';
import { importedDirOf, isImportId, listImportedIds, readImportMeta } from './import-store';
const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/;
const AGENT_ID_RE = /^[A-Za-z0-9._-]+$/;
@ -24,7 +25,10 @@ interface StateJson {
title?: string;
isCustomTitle?: boolean;
lastPrompt?: string;
agents?: Record<string, { homedir: string; type: 'main' | 'sub' | 'independent'; parentAgentId: string | null; swarmItem?: string }>;
// Agent metadata comes from an untrusted state.json (a corrupt or imported
// bundle may hold non-object entries like `{ "main": null }`), so the value
// type allows null and inventoryAgents skips anything that isn't an object.
agents?: Record<string, { homedir: string; type: 'main' | 'sub' | 'independent'; parentAgentId: string | null; swarmItem?: string } | null>;
custom?: Record<string, unknown>;
}
@ -45,11 +49,21 @@ export async function listSessions(home: string): Promise<SessionSummary[]> {
if (summary !== null) out.push(summary);
}
}
// Imported debug bundles live under <home>/imported/<importId>/ and surface
// in the same list, tagged so the UI can filter them.
for (const importId of await listImportedIds(home)) {
const dir = importedDirOf(home, importId);
const meta = await readImportMeta(home, importId);
const workDir = meta?.manifest?.workspaceDir ?? '';
const summary = await tryReadSummary(dir, importId, workDir, { imported: true, importMeta: meta });
if (summary !== null) out.push(summary);
}
out.sort((a, b) => b.updatedAt - a.updatedAt);
return out;
}
export async function readSessionDetail(home: string, sessionId: string): Promise<SessionDetail | null> {
if (isImportId(sessionId)) return readImportedDetail(home, sessionId);
const sessionDir = await findSessionDir(home, sessionId);
if (sessionDir === null) return null;
const index = await readSessionIndex(home);
@ -62,11 +76,38 @@ export async function readSessionDetail(home: string, sessionId: string): Promis
// still inspect the wire/context of a session whose state is corrupt.
if (state === null) {
const agents = await discoverAgentsFromDisk(sessionDir);
return { sessionId, sessionDir, workDir, state: null, agents };
return { sessionId, sessionDir, workDir, state: null, agents, imported: false, importMeta: null };
}
if (state.custom?.['imported_from_kimi_cli'] === true) return null;
const agents = await inventoryAgents(sessionDir, state);
return { sessionId, sessionDir, workDir, state, agents };
return { sessionId, sessionDir, workDir, state, agents, imported: false, importMeta: null };
}
/** Detail for an imported bundle. Same readers as a local session, but the
* directory is `imported/<id>/`, the workDir comes from the manifest, and
* agent homedirs are re-derived from the local extraction (state.json holds
* the exporting machine's absolute paths, which do not exist here). The
* `imported_from_kimi_cli` hide-filter is intentionally NOT applied the
* user imported this bundle deliberately. */
async function readImportedDetail(home: string, importId: string): Promise<SessionDetail | null> {
const sessionDir = importedDirOf(home, importId);
if (!(await pathExists(sessionDir))) return null;
const meta = await readImportMeta(home, importId);
const workDir = meta?.manifest?.workspaceDir ?? '';
const state = await readState(sessionDir);
if (state === null) {
const agents = await discoverAgentsFromDisk(sessionDir);
return { sessionId: importId, sessionDir, workDir, state: null, agents, imported: true, importMeta: meta };
}
// State is best-effort in a bundle: a readable state.json may still omit the
// `agents` map. When the inventory comes back empty, fall back to probing
// `agents/*` on disk so routes that require an agent (wire/context/…) still
// resolve `main`.
let agents = await inventoryAgents(sessionDir, state, true);
if (agents.length === 0) {
agents = await discoverAgentsFromDisk(sessionDir);
}
return { sessionId: importId, sessionDir, workDir, state, agents, imported: true, importMeta: meta };
}
/** Fallback inventory used when `state.json` is unreadable: walk
@ -115,12 +156,21 @@ async function discoverAgentsFromDisk(sessionDir: string): Promise<AgentInfo[]>
return out.sort((a, b) => compareAgentIds(a.agentId, b.agentId));
}
async function tryReadSummary(sessionDir: string, sessionId: string, workDir: string): Promise<SessionSummary | null> {
async function tryReadSummary(
sessionDir: string,
sessionId: string,
workDir: string,
opts: { imported?: boolean; importMeta?: ImportInfo | null } = {},
): Promise<SessionSummary | null> {
const imported = opts.imported ?? false;
const importMeta = opts.importMeta ?? null;
const state = await readState(sessionDir);
if (state === null) {
return brokenStateSummary(sessionDir, sessionId, workDir);
return brokenStateSummary(sessionDir, sessionId, workDir, imported, importMeta);
}
if (state.custom?.['imported_from_kimi_cli'] === true) return null;
// Local migrated-CLI sessions are hidden; an imported bundle is shown
// regardless because the user chose to import it.
if (!imported && state.custom?.['imported_from_kimi_cli'] === true) return null;
const mainWirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
const mainExists = await pathExists(mainWirePath);
@ -156,16 +206,25 @@ async function tryReadSummary(sessionDir: string, sessionId: string, workDir: st
mainWireRecordCount: mainCount,
wireProtocolVersion: protocolVersion,
health,
imported,
importMeta,
};
}
function brokenStateSummary(sessionDir: string, sessionId: string, workDir: string): SessionSummary {
function brokenStateSummary(
sessionDir: string,
sessionId: string,
workDir: string,
imported = false,
importMeta: ImportInfo | null = null,
): SessionSummary {
return {
sessionId, sessionDir, workDir,
title: null, lastPrompt: null, isCustomTitle: false,
createdAt: 0, updatedAt: 0,
agentCount: 0, mainAgentExists: false, mainWireRecordCount: 0,
wireProtocolVersion: null, health: 'broken_state',
imported, importMeta,
};
}
@ -195,10 +254,14 @@ async function readSessionIndex(home: string): Promise<Map<string, SessionIndexE
return out;
}
async function inventoryAgents(sessionDir: string, state: StateJson): Promise<AgentInfo[]> {
async function inventoryAgents(sessionDir: string, state: StateJson, deriveHomedir = false): Promise<AgentInfo[]> {
const result: AgentInfo[] = [];
for (const [id, meta] of Object.entries(state.agents ?? {})) {
if (!isSafeAgentId(id)) continue;
// A type-corrupt entry (e.g. `{ "main": null }`) must not throw on the
// field dereferences below; skip it so the empty-inventory fallback in
// readImportedDetail can recover the agent from disk instead.
if (typeof meta !== 'object' || meta === null) continue;
const wirePath = join(sessionDir, 'agents', id, 'wire.jsonl');
const exists = await pathExists(wirePath);
let readable = exists;
@ -218,7 +281,10 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise<Ag
agentId: id,
type: meta.type,
parentAgentId: meta.parentAgentId,
homedir: meta.homedir,
// For imported bundles the persisted homedir is the exporting machine's
// absolute path; re-derive it from the local extraction so blob reads
// (which join homedir) resolve under the imported directory.
homedir: deriveHomedir ? join(sessionDir, 'agents', id) : meta.homedir,
wireExists: readable,
wireRecordCount: info.count,
wireProtocolVersion: info.protocolVersion,

View file

@ -0,0 +1,240 @@
// apps/vis/server/src/lib/task-store.ts
//
// Read-only reader for background tasks, persisted by agent-core under each
// spawning agent's homedir at `<agentDir>/tasks/<taskId>.json`
// (+ `tasks/<taskId>/output.log`) — NOT the session root. Callers pass the
// agent homedir (`<session>/agents/<id>`).
//
// The visualizer never writes these files; it mirrors agent-core's on-disk
// layout (background/persist.ts) for reading only:
// - the same `VALID_TASK_ID` guard, so a corrupt / hand-edited filename
// cannot turn a log path into a traversal primitive;
// - the same legacy snake_case → current camelCase normalization, so old
// sessions list identically to how the CLI would list them.
import { open, readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import type {
BackgroundTaskInfo,
BackgroundTaskStatus,
} from './agent-record-types';
/** Task id format: `{prefix}-{8 chars of [0-9a-z]}`. Mirror of agent-core's
* `VALID_TASK_ID` (background/persist.ts). Enforced before deriving any
* output path so neither `../` nor a legacy `bg_<hex>` id can escape. */
const VALID_TASK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-z]{8}$/;
export function isSafeTaskId(id: string): boolean {
return VALID_TASK_ID.test(id);
}
function tasksDirOf(agentDir: string): string {
return join(agentDir, 'tasks');
}
function taskOutputFile(agentDir: string, taskId: string): string {
if (!VALID_TASK_ID.test(taskId)) {
throw new Error(`Invalid task id: "${taskId}"`);
}
return join(tasksDirOf(agentDir), taskId, 'output.log');
}
/**
* Enumerate all persisted background tasks for a session, normalized to the
* current `BackgroundTaskInfo` shape and sorted newest-first by start time.
*
* Silently skips: filenames that don't match `VALID_TASK_ID`, files that fail
* to read/parse, and records that are neither the current nor the legacy
* task shape matching agent-core's tolerant `listTasks`.
*/
export async function listBackgroundTasks(
agentDir: string,
): Promise<BackgroundTaskInfo[]> {
const dir = tasksDirOf(agentDir);
let entries: import('node:fs').Dirent[];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const out: BackgroundTaskInfo[] = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const id = entry.name.slice(0, -'.json'.length);
if (!VALID_TASK_ID.test(id)) continue;
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(join(dir, entry.name), 'utf8'));
} catch {
continue;
}
if (!isReadablePersistedTask(parsed)) continue;
try {
out.push(normalizePersistedTask(parsed));
} catch {
// A record can pass the shape guard but still hold type-corrupt fields
// (e.g. a legacy `stop_reason` that is a number). Honour the
// silently-skips contract instead of failing the whole listing.
continue;
}
}
// Newest first; tasks with no start time sort last.
out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
return out;
}
/** Byte size of a task's `output.log` (0 when absent or unreadable). */
export async function taskOutputSizeBytes(
agentDir: string,
taskId: string,
): Promise<number> {
try {
return (await stat(taskOutputFile(agentDir, taskId))).size;
} catch {
return 0;
}
}
export interface TaskOutputWindow {
/** Byte offset this window starts at (clamped to >= 0). */
offset: number;
/** Byte offset immediately after this window pass it as the next
* `offset` to page forward. Exact (server-computed bytesRead), so paging
* never drifts even if a window boundary splits a multi-byte char. */
nextOffset: number;
/** Total byte size of the log on disk. */
size: number;
/** UTF-8 decoded window content. */
content: string;
/** True when this window reaches EOF. */
eof: boolean;
}
/**
* Read a byte window of a task's `output.log`.
*
* Reads at most `maxBytes` bytes starting at byte `offset`. A window past EOF
* is clamped to whatever remains; an offset at/after EOF yields empty content.
* Mirrors agent-core's `readTaskOutputBytes` so large logs page identically.
*/
export async function readTaskOutput(
agentDir: string,
taskId: string,
offset: number,
maxBytes: number,
): Promise<TaskOutputWindow> {
const start = Math.max(0, Math.trunc(offset));
const limit = Math.max(0, Math.trunc(maxBytes));
let handle;
try {
handle = await open(taskOutputFile(agentDir, taskId), 'r');
} catch {
return { offset: start, nextOffset: start, size: 0, content: '', eof: true };
}
try {
const size = (await handle.stat()).size;
if (limit === 0 || start >= size) {
return { offset: start, nextOffset: start, size, content: '', eof: start >= size };
}
const length = Math.min(limit, size - start);
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(buffer, 0, length, start);
const content = buffer.toString('utf-8', 0, bytesRead);
const nextOffset = start + bytesRead;
return { offset: start, nextOffset, size, content, eof: nextOffset >= size };
} catch {
return { offset: start, nextOffset: start, size: 0, content: '', eof: true };
} finally {
await handle.close();
}
}
// ── normalization (ported from agent-core/agent/background/persist.ts) ───────
type LegacyBackgroundTaskStatus =
| 'running'
| 'awaiting_approval'
| 'completed'
| 'failed'
| 'killed'
| 'lost';
interface LegacyPersistedTask {
readonly task_id: string;
readonly command: string;
readonly description: string;
readonly pid: number;
readonly started_at: number;
readonly ended_at: number | null;
readonly exit_code: number | null;
readonly status: LegacyBackgroundTaskStatus;
readonly timed_out?: boolean;
readonly stop_reason?: string;
readonly timeout_ms?: number;
readonly agent_id?: string;
readonly subagent_type?: string;
}
type DiskPersistedTask = BackgroundTaskInfo | LegacyPersistedTask;
function normalizePersistedTask(task: DiskPersistedTask): BackgroundTaskInfo {
if (isLegacyPersistedTask(task)) return legacyPersistedTaskToInfo(task);
return { ...task, detached: task.detached ?? true };
}
function legacyPersistedTaskToInfo(task: LegacyPersistedTask): BackgroundTaskInfo {
const status = legacyStatusToCurrent(task);
const base = {
taskId: task.task_id,
description: task.description,
status,
detached: true,
startedAt: task.started_at,
endedAt: task.ended_at,
stopReason: optionalNonEmptyString(task.stop_reason),
timeoutMs: typeof task.timeout_ms === 'number' ? task.timeout_ms : undefined,
};
if (task.task_id.startsWith('agent-')) {
return {
...base,
kind: 'agent',
agentId: optionalNonEmptyString(task.agent_id),
subagentType: optionalNonEmptyString(task.subagent_type),
};
}
return {
...base,
kind: 'process',
command: task.command,
pid: task.pid,
exitCode: task.exit_code,
};
}
function legacyStatusToCurrent(task: LegacyPersistedTask): BackgroundTaskStatus {
if (task.status === 'awaiting_approval') return 'running';
if (task.status === 'failed' && task.timed_out === true) return 'timed_out';
return task.status;
}
function isReadablePersistedTask(obj: unknown): obj is DiskPersistedTask {
return (
isRecord(obj) &&
(typeof obj['taskId'] === 'string' || typeof obj['task_id'] === 'string')
);
}
function isLegacyPersistedTask(task: DiskPersistedTask): task is LegacyPersistedTask {
return 'task_id' in task;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function optionalNonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}

View file

@ -0,0 +1,130 @@
// apps/vis/server/src/lib/zip-import.ts
//
// Safe extraction of a user-supplied debug zip into a destination directory.
//
// The zip comes from someone else's machine via `/export-debug-zip`, so it is
// untrusted input: every entry path is validated against path traversal
// ("zip slip"), and total entry-count / uncompressed-size caps guard against
// zip bombs. Only regular files are written; directories are created lazily.
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { dirname, resolve, sep } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fromBuffer, type Entry, type ZipFile } from 'yauzl';
export interface ExtractOptions {
/** Reject once this many entries have been seen. */
readonly maxEntries?: number;
/** Reject once the summed uncompressed size exceeds this many bytes. */
readonly maxTotalBytes?: number;
}
const DEFAULT_MAX_ENTRIES = 50_000;
const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
export class ZipImportError extends Error {}
/**
* Resolve a zip entry name to an absolute path under `root`, or return null
* when the entry would escape it (zip slip). Exposed for direct testing of
* the path-traversal guard, which is otherwise hard to exercise because zip
* writers refuse to emit `..` entries.
*/
export function resolveSafeTarget(root: string, entryName: string): string | null {
const absRoot = resolve(root);
const rootPrefix = absRoot + sep;
const rel = entryName.replaceAll('\\', '/');
const target = resolve(absRoot, rel);
if (target !== absRoot && !target.startsWith(rootPrefix)) return null;
return target;
}
/**
* Extract every file entry of `zipBuffer` under `destDir`, returning the list
* of written zip-relative paths (forward-slashed). `destDir` must already be a
* safe, caller-owned directory; this function never writes outside it.
*/
export async function extractZip(
zipBuffer: Buffer,
destDir: string,
options: ExtractOptions = {},
): Promise<string[]> {
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
const root = resolve(destDir);
const zip = await openZip(zipBuffer);
const written: string[] = [];
let entryCount = 0;
let totalBytes = 0;
return new Promise<string[]>((resolvePromise, reject) => {
const fail = (message: string): void => {
zip.close();
reject(new ZipImportError(message));
};
zip.readEntry();
zip.on('entry', (entry: Entry) => {
entryCount += 1;
if (entryCount > maxEntries) {
fail(`zip has too many entries (> ${maxEntries})`);
return;
}
totalBytes += entry.uncompressedSize;
if (totalBytes > maxTotalBytes) {
fail(`zip uncompressed size exceeds ${maxTotalBytes} bytes`);
return;
}
// Directory entries end with '/'. Files inside still create their dirs.
if (entry.fileName.endsWith('/')) {
zip.readEntry();
return;
}
const rel = entry.fileName.replaceAll('\\', '/');
const target = resolveSafeTarget(root, rel);
if (target === null) {
fail(`zip entry escapes the import directory: "${entry.fileName}"`);
return;
}
zip.openReadStream(entry, (err, readStream) => {
if (err !== null || readStream === undefined) {
fail(`failed to read zip entry "${entry.fileName}": ${err?.message ?? 'unknown'}`);
return;
}
void mkdir(dirname(target), { recursive: true })
.then(() => pipeline(readStream, createWriteStream(target)))
.then(() => {
written.push(rel);
zip.readEntry();
})
.catch((error: unknown) => {
fail(`failed to write "${rel}": ${(error as Error).message}`);
});
});
});
zip.on('end', () => {
resolvePromise(written);
});
zip.on('error', (err: Error) => {
reject(new ZipImportError(`corrupt zip: ${err.message}`));
});
});
}
function openZip(buffer: Buffer): Promise<ZipFile> {
return new Promise<ZipFile>((resolvePromise, reject) => {
fromBuffer(buffer, { lazyEntries: true }, (err, zipfile) => {
if (err !== null || zipfile === undefined) {
reject(new ZipImportError(`not a valid zip file: ${err?.message ?? 'unknown'}`));
return;
}
resolvePromise(zipfile);
});
});
}

View file

@ -0,0 +1,32 @@
import { Hono } from 'hono';
import { KIMI_CODE_HOME } from '../config';
import type { CronTask } from '../lib/agent-record-types';
import { readSessionDetail } from '../lib/session-store';
import { listCronTasks } from '../lib/cron-store';
export function cronRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
r.get('/:id/cron', async (c) => {
const id = c.req.param('id');
const detail = await readSessionDetail(home, id);
if (!detail) {
return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
}
// Cron jobs are persisted under each (non-sub) agent's homedir at
// `<homedir>/cron`, not the session root. Aggregate across agents; sub
// agents have no cron directory and simply contribute nothing.
const cron: CronTask[] = [];
const seen = new Set<string>();
for (const agent of detail.agents) {
for (const job of await listCronTasks(agent.homedir)) {
if (seen.has(job.id)) continue;
seen.add(job.id);
cron.push(job);
}
}
cron.sort((a, b) => a.createdAt - b.createdAt);
return c.json({ sessionId: id, cron });
});
return r;
}

View file

@ -0,0 +1,47 @@
import { Hono } from 'hono';
import { KIMI_CODE_HOME } from '../config';
import { importSessionZip } from '../lib/import-store';
import { ZipImportError } from '../lib/zip-import';
/** Reject obviously-too-large uploads before buffering the whole body. The zip
* itself (compressed) is capped here; the uncompressed cap lives in
* `extractZip`. */
const MAX_ZIP_BYTES = 500 * 1024 * 1024; // 500 MiB
export function importsRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
// Upload a `/export-debug-zip` bundle. The raw zip bytes are the request
// body; the original filename may be passed via `?name=` for display.
r.post('/', async (c) => {
const declared = Number(c.req.header('content-length') ?? '0');
if (Number.isFinite(declared) && declared > MAX_ZIP_BYTES) {
return c.json({ error: 'zip is too large', code: 'BAD_REQUEST' }, 400);
}
const name = c.req.query('name') ?? null;
let buffer: Buffer;
try {
buffer = Buffer.from(await c.req.arrayBuffer());
} catch {
return c.json({ error: 'could not read upload body', code: 'BAD_REQUEST' }, 400);
}
if (buffer.length === 0) {
return c.json({ error: 'empty upload', code: 'BAD_REQUEST' }, 400);
}
if (buffer.length > MAX_ZIP_BYTES) {
return c.json({ error: 'zip is too large', code: 'BAD_REQUEST' }, 400);
}
try {
const meta = await importSessionZip(home, buffer, name, new Date());
return c.json({ sessionId: meta.importId, importMeta: meta });
} catch (error) {
if (error instanceof ZipImportError) {
return c.json({ error: error.message, code: 'BAD_REQUEST' }, 400);
}
return c.json({ error: (error as Error).message, code: 'READ_ERROR' }, 500);
}
});
return r;
}

View file

@ -0,0 +1,48 @@
import { Hono } from 'hono';
import { join } from 'node:path';
import { KIMI_CODE_HOME } from '../config';
import { discoverLogFiles, readLogs } from '../lib/log-reader';
import { readSessionDetail } from '../lib/session-store';
const SESSION_LOG_REL = ['logs', 'kimi-code.log'] as const;
const GLOBAL_LOG_REL = ['logs', 'global', 'kimi-code.log'] as const;
const HOME_GLOBAL_LOG_REL = ['logs', 'kimi-code.log'] as const;
export function logsRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
r.get('/:id/logs', async (c) => {
const id = c.req.param('id');
const which = c.req.query('which') === 'global' ? 'global' : 'session';
const detail = await readSessionDetail(home, id);
if (!detail) {
return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
}
const sessionLog = join(detail.sessionDir, ...SESSION_LOG_REL);
// The global diagnostic log is a single shared file. In an exported bundle
// it is captured under the session dir (logs/global/kimi-code.log); for a
// live local session it lives at <KIMI_CODE_HOME>/logs/kimi-code.log
// (agent-core's resolveGlobalLogPath), NOT under the session dir.
const globalLog = detail.imported
? join(detail.sessionDir, ...GLOBAL_LOG_REL)
: join(home, ...HOME_GLOBAL_LOG_REL);
// Either log may have rotated (kimi-code.log.1, .2, …); discover the active
// file plus its archives so a bundle with only rotated logs still surfaces.
const sessionFiles = await discoverLogFiles(sessionLog);
const globalFiles = await discoverLogFiles(globalLog);
const available = {
session: sessionFiles.length > 0,
global: globalFiles.length > 0,
};
const targetFiles = which === 'global' ? globalFiles : sessionFiles;
const result = await readLogs(targetFiles);
return c.json({
sessionId: id,
which,
available,
lines: result?.lines ?? [],
truncated: result?.truncated ?? false,
});
});
return r;
}

View file

@ -0,0 +1,90 @@
import { Hono } from 'hono';
import { KIMI_CODE_HOME } from '../config';
import type { BackgroundTaskEntry } from '../lib/agent-record-types';
import { readSessionDetail } from '../lib/session-store';
import {
isSafeTaskId,
listBackgroundTasks,
readTaskOutput,
taskOutputSizeBytes,
} from '../lib/task-store';
/** Default output-log window size: 256 KiB. Large enough to show a whole
* typical log in one fetch, bounded so a multi-MB log pages instead of
* loading wholesale. Overridable via `?limit=`. */
const DEFAULT_OUTPUT_LIMIT = 256 * 1024;
const MAX_OUTPUT_LIMIT = 4 * 1024 * 1024;
export function tasksRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
// List background tasks (process / agent / question) for a session. Tasks are
// persisted under each spawning agent's homedir (`<homedir>/tasks`), NOT the
// session root, so aggregate across every agent in the session.
r.get('/:id/tasks', async (c) => {
const id = c.req.param('id');
const detail = await readSessionDetail(home, id);
if (!detail) {
return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
}
const entries: BackgroundTaskEntry[] = [];
for (const agent of detail.agents) {
const tasks = await listBackgroundTasks(agent.homedir);
for (const task of tasks) {
const outputSizeBytes = await taskOutputSizeBytes(agent.homedir, task.taskId);
entries.push({ task, agentId: agent.agentId, outputSizeBytes, outputExists: outputSizeBytes > 0 });
}
}
// Newest first across all agents.
entries.sort((a, b) => (b.task.startedAt ?? 0) - (a.task.startedAt ?? 0));
return c.json({ sessionId: id, tasks: entries });
});
// Read a byte-window of a single task's output.log. The task may belong to
// any agent, so locate the agent whose tasks/ directory holds it.
r.get('/:id/tasks/:taskId/output', async (c) => {
const id = c.req.param('id');
const taskId = c.req.param('taskId');
if (!isSafeTaskId(taskId)) {
return c.json({ error: 'invalid task id', code: 'BAD_REQUEST' }, 400);
}
const offset = parseNonNegativeInt(c.req.query('offset'), 0);
const limit = Math.min(
parseNonNegativeInt(c.req.query('limit'), DEFAULT_OUTPUT_LIMIT),
MAX_OUTPUT_LIMIT,
);
const detail = await readSessionDetail(home, id);
if (!detail) {
return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
}
// Prefer the agent whose log actually has bytes; otherwise any agent's dir
// yields the same empty window. An explicit ?agent= short-circuits the scan.
const hinted = c.req.query('agent');
let dir = detail.agents.find((a) => a.agentId === hinted)?.homedir ?? detail.agents[0]?.homedir ?? detail.sessionDir;
for (const agent of detail.agents) {
if ((await taskOutputSizeBytes(agent.homedir, taskId)) > 0) {
dir = agent.homedir;
break;
}
}
const window = await readTaskOutput(dir, taskId, offset, limit);
return c.json({
sessionId: id,
taskId,
offset: window.offset,
nextOffset: window.nextOffset,
size: window.size,
content: window.content,
eof: window.eof,
});
});
return r;
}
function parseNonNegativeInt(raw: string | undefined, fallback: number): number {
if (raw === undefined) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}

View file

@ -127,6 +127,19 @@ describe('context-projector', () => {
]);
});
it('does not reset contextTokens on a zero-usage step.end', () => {
const entries = [
{ lineNo: 1, data: { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1', turnId: 'T', step: 0 } }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's1', turnId: 'T', step: 0, usage: { inputOther: 100, output: 20, inputCacheRead: 80, inputCacheCreation: 0 } } }, raw: {} },
{ lineNo: 3, data: { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's2', turnId: 'T', step: 1 } }, raw: {} },
// content-filtered response: usage all zero — must NOT reset the fill to 0.
{ lineNo: 4, data: { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's2', turnId: 'T', step: 1, finishReason: 'filtered', usage: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } } }, raw: {} },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proj = projectContext(entries as any);
expect(proj.contextTokens).toBe(200); // step1 fill 200, kept across the zero step
});
// ---- Fix G: tool.result content must match what the model saw ---------------
// agent-core's `ContextMemory.appendLoopEvent` (`tool.result` case) stores
// `createToolMessage(toolCallId, toolResultOutputForModel(event.result))`, NOT

View file

@ -0,0 +1,63 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { buildSessionFixture } from '../fixtures/build';
import { isSafeCronId, listCronTasks } from '../../src/lib/cron-store';
async function writeCron(sessionDir: string, fileName: string, body: unknown): Promise<void> {
const dir = join(sessionDir, 'cron');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, fileName), JSON.stringify(body));
}
describe('cron-store', () => {
let cleanup: (() => Promise<void>) | null = null;
afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; });
it('lists valid cron tasks sorted by creation time', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
// Written in the real on-disk shape — this doubles as a drift guard for
// the local CronTask mirror in agent-record-types.ts.
await writeCron(sessionDir, 'a1b2c3d4.json', {
id: 'a1b2c3d4', cron: '0 9 * * *', prompt: 'daily standup',
createdAt: 2000, recurring: true, lastFiredAt: 5000,
});
await writeCron(sessionDir, 'beefbeef.json', {
id: 'beefbeef', cron: '*/5 * * * *', prompt: 'poll ci',
createdAt: 1000, recurring: false,
});
const cron = await listCronTasks(sessionDir);
expect(cron.map((t) => t.id)).toEqual(['beefbeef', 'a1b2c3d4']); // createdAt asc
expect(cron[1]).toMatchObject({
cron: '0 9 * * *', prompt: 'daily standup', recurring: true, lastFiredAt: 5000,
});
});
it('skips bad ids, corrupt json, and records missing required fields', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await writeCron(sessionDir, 'NOTHEX12.json', { id: 'NOTHEX12', cron: 'x', prompt: 'p', createdAt: 1 });
await mkdir(join(sessionDir, 'cron'), { recursive: true });
await writeFile(join(sessionDir, 'cron', 'deadbeef.json'), '{ broken');
await writeCron(sessionDir, 'cafecafe.json', { id: 'cafecafe', cron: '* * * * *' }); // no prompt/createdAt
expect(await listCronTasks(sessionDir)).toEqual([]);
});
it('returns [] when there is no cron directory', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
expect(await listCronTasks(sessionDir)).toEqual([]);
});
it('isSafeCronId accepts 8-hex ids only', () => {
expect(isSafeCronId('a1b2c3d4')).toBe(true);
expect(isSafeCronId('deadbeef')).toBe(true);
expect(isSafeCronId('DEADBEEF')).toBe(false);
expect(isSafeCronId('abc')).toBe(false);
expect(isSafeCronId('../escape')).toBe(false);
});
});

View file

@ -0,0 +1,100 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { ZipFile } from 'yazl';
import { importSessionZip, isImportId, listImportedIds, readImportMeta, deleteImported } from '../../src/lib/import-store';
import { resolveSafeTarget } from '../../src/lib/zip-import';
/** Build an in-memory zip from a {path: contents} map (yazl refuses to emit
* `..` entries, so traversal is tested via resolveSafeTarget directly). */
function buildZip(entries: Record<string, string>): Promise<Buffer> {
return new Promise((resolve, reject) => {
const zip = new ZipFile();
for (const [name, data] of Object.entries(entries)) {
zip.addBuffer(Buffer.from(data, 'utf8'), name);
}
zip.end();
const chunks: Buffer[] = [];
(zip.outputStream as NodeJS.ReadableStream).on('data', (c: Buffer) => chunks.push(c));
(zip.outputStream as NodeJS.ReadableStream).on('end', () => { resolve(Buffer.concat(chunks)); });
(zip.outputStream as NodeJS.ReadableStream).on('error', reject);
});
}
const META_LINE = JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1 });
const WIRE = `${META_LINE}\n`;
function validBundle(): Record<string, string> {
return {
'manifest.json': JSON.stringify({ sessionId: 'session_orig', kimiCodeVersion: '0.20.2', workspaceDir: '/home/u/proj', title: 'imported demo' }),
'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'imported demo', agents: { main: { homedir: '/orig/agents/main', type: 'main', parentAgentId: null } }, custom: {} }),
'agents/main/wire.jsonl': WIRE,
'logs/kimi-code.log': '2026-06-01T00:00:00.000Z INFO hello k=v\n',
};
}
describe('import-store', () => {
let home: string | null = null;
afterEach(async () => { if (home) await rm(home, { recursive: true, force: true }); home = null; });
it('imports a valid bundle and lists it with manifest metadata', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-import-'));
const zip = await buildZip(validBundle());
const meta = await importSessionZip(home, zip, 'demo.zip', new Date('2026-06-29T00:00:00.000Z'));
expect(isImportId(meta.importId)).toBe(true);
expect(meta.originalName).toBe('demo.zip');
expect(meta.manifest?.sessionId).toBe('session_orig');
expect(meta.manifest?.workspaceDir).toBe('/home/u/proj');
// Extracted to imported/<id>/ with the session shape intact.
const dir = join(home, 'imported', meta.importId);
expect((await stat(join(dir, 'agents', 'main', 'wire.jsonl'))).isFile()).toBe(true);
expect((await stat(join(dir, 'logs', 'kimi-code.log'))).isFile()).toBe(true);
const ids = await listImportedIds(home);
expect(ids).toContain(meta.importId);
const reread = await readImportMeta(home, meta.importId);
expect(reread?.importId).toBe(meta.importId);
});
it('rejects a zip with no main wire and cleans up', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-import-'));
const zip = await buildZip({ 'manifest.json': '{}', 'state.json': '{}' });
await expect(importSessionZip(home, zip, null, new Date())).rejects.toThrow(/session bundle/);
// No partial directory left behind.
expect(await listImportedIds(home)).toEqual([]);
});
it('deletes an imported bundle', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-import-'));
const meta = await importSessionZip(home, await buildZip(validBundle()), null, new Date());
expect(await deleteImported(home, meta.importId)).toBe(true);
expect(await listImportedIds(home)).toEqual([]);
expect(await deleteImported(home, meta.importId)).toBe(false);
});
it('isImportId only matches the imp_ scheme', () => {
expect(isImportId('imp_0123456789ab')).toBe(true);
expect(isImportId('session_abc')).toBe(false);
expect(isImportId('imp_xyz')).toBe(false);
expect(isImportId('../escape')).toBe(false);
});
});
describe('resolveSafeTarget (zip-slip guard)', () => {
const root = '/tmp/imp/abc';
it('accepts in-tree paths', () => {
expect(resolveSafeTarget(root, 'state.json')).toBe('/tmp/imp/abc/state.json');
expect(resolveSafeTarget(root, 'agents/main/wire.jsonl')).toBe('/tmp/imp/abc/agents/main/wire.jsonl');
});
it('rejects traversal and absolute escapes', () => {
expect(resolveSafeTarget(root, '../evil')).toBeNull();
expect(resolveSafeTarget(root, '../../etc/passwd')).toBeNull();
expect(resolveSafeTarget(root, 'a/../../b')).toBeNull();
expect(resolveSafeTarget(root, '/etc/passwd')).toBeNull();
});
});

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { parseLogLine } from '../../src/lib/log-reader';
describe('parseLogLine', () => {
it('parses time, level, message, and trailing key=value fields', () => {
const line = '2026-06-15T05:32:08.722Z INFO llm config turnStep=0.1 provider=openai model=coding-model-okapi thinkingEffort=high';
const parsed = parseLogLine(line, 7);
expect(parsed.lineNo).toBe(7);
expect(parsed.time).toBe('2026-06-15T05:32:08.722Z');
expect(parsed.level).toBe('INFO');
expect(parsed.message).toBe('llm config');
expect(parsed.fields).toEqual({
turnStep: '0.1',
provider: 'openai',
model: 'coding-model-okapi',
thinkingEffort: 'high',
});
});
it('handles a message with no fields', () => {
const parsed = parseLogLine('2026-06-15T05:32:16.680Z WARN something happened', 1);
expect(parsed.level).toBe('WARN');
expect(parsed.message).toBe('something happened');
expect(parsed.fields).toEqual({});
});
it('keeps unparseable lines verbatim as a message', () => {
const parsed = parseLogLine(' at someStackFrame (file.ts:1:2)', 3);
expect(parsed.time).toBeNull();
expect(parsed.level).toBeNull();
expect(parsed.message).toBe(' at someStackFrame (file.ts:1:2)');
expect(parsed.raw).toBe(' at someStackFrame (file.ts:1:2)');
});
});

View file

@ -0,0 +1,155 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { buildSessionFixture } from '../fixtures/build';
import {
isSafeTaskId,
listBackgroundTasks,
readTaskOutput,
taskOutputSizeBytes,
} from '../../src/lib/task-store';
async function writeTask(sessionDir: string, fileName: string, body: unknown): Promise<void> {
const dir = join(sessionDir, 'tasks');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, fileName), JSON.stringify(body));
}
describe('task-store', () => {
let cleanup: (() => Promise<void>) | null = null;
afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; });
it('lists current-shape tasks of every kind, normalized and newest-first', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await writeTask(sessionDir, 'bash-aaaaaaaa.json', {
taskId: 'bash-aaaaaaaa', kind: 'process', description: 'run build',
command: 'pnpm build', pid: 4242, exitCode: 0, status: 'completed',
detached: true, startedAt: 1000, endedAt: 2000,
});
await writeTask(sessionDir, 'agent-bbbbbbbb.json', {
taskId: 'agent-bbbbbbbb', kind: 'agent', description: 'explore repo',
agentId: 'agent-1', subagentType: 'Explore', status: 'running',
detached: true, startedAt: 3000, endedAt: null,
});
await writeTask(sessionDir, 'question-cccccccc.json', {
taskId: 'question-cccccccc', kind: 'question', description: 'ask user',
questionCount: 2, status: 'running', detached: false,
startedAt: 2500, endedAt: null,
});
const tasks = await listBackgroundTasks(sessionDir);
expect(tasks.map((t) => t.taskId)).toEqual([
'agent-bbbbbbbb', // startedAt 3000
'question-cccccccc', // 2500
'bash-aaaaaaaa', // 1000
]);
const proc = tasks.find((t) => t.kind === 'process');
expect(proc).toMatchObject({ command: 'pnpm build', pid: 4242, exitCode: 0 });
const question = tasks.find((t) => t.kind === 'question');
expect(question).toMatchObject({ questionCount: 2, detached: false });
});
it('normalizes legacy snake_case tasks to the current shape', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await writeTask(sessionDir, 'bash-dddddddd.json', {
task_id: 'bash-dddddddd', command: 'sleep 1', description: 'legacy proc',
pid: 9, started_at: 100, ended_at: 200, exit_code: null,
status: 'failed', timed_out: true, timeout_ms: 5000,
});
await writeTask(sessionDir, 'agent-eeeeeeee.json', {
task_id: 'agent-eeeeeeee', command: '', description: 'legacy agent',
pid: 0, started_at: 50, ended_at: null, exit_code: null,
status: 'awaiting_approval', agent_id: 'agent-2', subagent_type: 'general',
});
const tasks = await listBackgroundTasks(sessionDir);
const proc = tasks.find((t) => t.taskId === 'bash-dddddddd')!;
expect(proc.kind).toBe('process');
expect(proc.status).toBe('timed_out'); // failed + timed_out → timed_out
expect(proc).toMatchObject({ detached: true, timeoutMs: 5000 });
const agent = tasks.find((t) => t.taskId === 'agent-eeeeeeee')!;
expect(agent.kind).toBe('agent');
expect(agent.status).toBe('running'); // awaiting_approval → running
expect(agent).toMatchObject({ agentId: 'agent-2', subagentType: 'general' });
});
it('skips bad filenames, corrupt json, and unrecognized records', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await writeTask(sessionDir, 'not-a-valid-id.json', { taskId: 'x', kind: 'process' });
await mkdir(join(sessionDir, 'tasks'), { recursive: true });
await writeFile(join(sessionDir, 'tasks', 'bash-ffffffff.json'), '{ broken');
await writeTask(sessionDir, 'bash-99999999.json', { unrelated: true });
expect(await listBackgroundTasks(sessionDir)).toEqual([]);
});
it('tolerates type-corrupt legacy fields instead of failing the whole listing', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await writeTask(sessionDir, 'bash-aaaaaaaa.json', {
taskId: 'bash-aaaaaaaa', kind: 'process', description: 'ok', command: 'x',
pid: 1, exitCode: 0, status: 'completed', detached: true, startedAt: 100, endedAt: 200,
});
// Passes the shape guard (has task_id) but stop_reason / subagent_type are
// numbers — the old code threw on `.trim()` and lost ALL tasks.
await writeTask(sessionDir, 'agent-bbbbbbbb.json', {
task_id: 'agent-bbbbbbbb', command: '', description: 'bad', pid: 0,
started_at: 50, ended_at: null, exit_code: null, status: 'failed',
stop_reason: 5, subagent_type: 5,
});
const tasks = await listBackgroundTasks(sessionDir);
// No throw; both tasks listed, the corrupt fields coerced away.
expect(tasks.map((t) => t.taskId).toSorted()).toEqual(['agent-bbbbbbbb', 'bash-aaaaaaaa']);
const bad = tasks.find((t) => t.taskId === 'agent-bbbbbbbb')!;
expect(bad.stopReason).toBeUndefined();
expect(bad.kind === 'agent' ? bad.subagentType : 'n/a').toBeUndefined();
});
it('returns [] when there is no tasks directory', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
expect(await listBackgroundTasks(sessionDir)).toEqual([]);
});
it('reads output.log byte windows with size + eof', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const dir = join(sessionDir, 'tasks', 'bash-12345678');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'output.log'), 'hello world');
expect(await taskOutputSizeBytes(sessionDir, 'bash-12345678')).toBe(11);
const head = await readTaskOutput(sessionDir, 'bash-12345678', 0, 5);
expect(head).toMatchObject({ offset: 0, nextOffset: 5, size: 11, content: 'hello', eof: false });
// Paging forward from the previous window's nextOffset reaches EOF exactly.
const tail = await readTaskOutput(sessionDir, 'bash-12345678', head.nextOffset, 100);
expect(tail).toMatchObject({ offset: 5, nextOffset: 11, size: 11, content: ' world', eof: true });
const past = await readTaskOutput(sessionDir, 'bash-12345678', 50, 10);
expect(past).toMatchObject({ content: '', eof: true });
});
it('returns an empty window when the log is absent', async () => {
const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const w = await readTaskOutput(sessionDir, 'bash-00000000', 0, 100);
expect(w).toMatchObject({ size: 0, content: '', eof: true });
});
it('isSafeTaskId guards traversal', () => {
expect(isSafeTaskId('bash-1a2b3c4d')).toBe(true);
expect(isSafeTaskId('agent-deadbeef')).toBe(true);
expect(isSafeTaskId('../escape')).toBe(false);
expect(isSafeTaskId('bash')).toBe(false);
expect(isSafeTaskId('bg_abcd')).toBe(false);
});
});

View file

@ -0,0 +1,49 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { buildSessionFixture } from '../fixtures/build';
import { cronRoute } from '../../src/routes/cron';
describe('cron route', () => {
let cleanup: (() => Promise<void>) | null = null;
afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; });
it('GET /:id/cron returns the persisted cron tasks', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
// Cron lives under the main agent's homedir, not the session root.
const dir = join(sessionDir, 'agents', 'main', 'cron');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'a1b2c3d4.json'), JSON.stringify({
id: 'a1b2c3d4', cron: '0 9 * * *', prompt: 'standup', createdAt: 1, recurring: true,
}));
const app = cronRoute(home);
const res = await app.request('/session_fixture/cron');
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string; cron: { id: string; cron: string }[] };
expect(body.sessionId).toBe('session_fixture');
expect(body.cron).toHaveLength(1);
expect(body.cron[0]).toMatchObject({ id: 'a1b2c3d4', cron: '0 9 * * *', prompt: 'standup' });
});
it('GET /:id/cron returns [] when there are no cron tasks', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = cronRoute(home);
const res = await app.request('/session_fixture/cron');
expect(res.status).toBe(200);
expect(((await res.json()) as { cron: unknown[] }).cron).toEqual([]);
});
it('returns 404 for a missing session', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = cronRoute(home);
const res = await app.request('/no-such-session/cron');
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'NOT_FOUND' });
});
});

View file

@ -0,0 +1,152 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { ZipFile } from 'yazl';
import { importsRoute } from '../../src/routes/imports';
import { logsRoute } from '../../src/routes/logs';
import { sessionsRoute } from '../../src/routes/sessions';
import { wireRoute } from '../../src/routes/wire';
function buildZip(entries: Record<string, string>): Promise<Buffer> {
return new Promise((resolve, reject) => {
const zip = new ZipFile();
for (const [name, data] of Object.entries(entries)) zip.addBuffer(Buffer.from(data, 'utf8'), name);
zip.end();
const chunks: Buffer[] = [];
(zip.outputStream as NodeJS.ReadableStream).on('data', (c: Buffer) => chunks.push(c));
(zip.outputStream as NodeJS.ReadableStream).on('end', () => { resolve(Buffer.concat(chunks)); });
(zip.outputStream as NodeJS.ReadableStream).on('error', reject);
});
}
const META = JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1 });
const PROMPT = JSON.stringify({ type: 'turn.prompt', time: 2, input: [{ type: 'text', text: 'hi' }], origin: { kind: 'user' } });
function bundle(): Record<string, string> {
return {
'manifest.json': JSON.stringify({ sessionId: 'session_orig', kimiCodeVersion: '0.20.2', workspaceDir: '/w/proj', title: 'demo' }),
'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'demo', agents: { main: { homedir: '/orig/agents/main', type: 'main', parentAgentId: null } }, custom: {} }),
'agents/main/wire.jsonl': `${META}\n${PROMPT}\n`,
'logs/kimi-code.log': '2026-06-01T00:00:00.000Z INFO boot step=0\n2026-06-01T00:00:01.000Z ERROR oops code=500\n',
};
}
async function importBundle(home: string): Promise<string> {
const app = importsRoute(home);
const res = await app.request('/?name=demo.zip', { method: 'POST', body: await buildZip(bundle()) });
expect(res.status).toBe(200);
return ((await res.json()) as { sessionId: string }).sessionId;
}
describe('imports + logs routes', () => {
let home: string | null = null;
afterEach(async () => { if (home) await rm(home, { recursive: true, force: true }); home = null; });
it('imports a zip and surfaces it in the session list tagged imported', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-imp-route-'));
const importId = await importBundle(home);
const list = sessionsRoute(home);
const res = await list.request('/');
const body = (await res.json()) as { sessions: { sessionId: string; imported: boolean; importMeta: { manifest: { kimiCodeVersion: string } | null } | null }[] };
const imported = body.sessions.find((s) => s.sessionId === importId);
expect(imported).toBeDefined();
expect(imported!.imported).toBe(true);
expect(imported!.importMeta?.manifest?.kimiCodeVersion).toBe('0.20.2');
});
it('serves the imported session wire through the existing wire route', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-imp-route-'));
const importId = await importBundle(home);
const res = await wireRoute(home).request(`/${importId}/wire?agent=main`);
expect(res.status).toBe(200);
const body = (await res.json()) as { records: { data: { type: string } }[] };
// metadata is the wire header; the one remaining record is the prompt.
expect(body.records.length).toBeGreaterThanOrEqual(1);
expect(body.records.some((r) => r.data.type === 'turn.prompt')).toBe(true);
});
it('parses the imported session log', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-imp-route-'));
const importId = await importBundle(home);
const res = await logsRoute(home).request(`/${importId}/logs`);
expect(res.status).toBe(200);
const body = (await res.json()) as { available: { session: boolean }; lines: { level: string | null; fields: Record<string, string> }[] };
expect(body.available.session).toBe(true);
expect(body.lines).toHaveLength(2);
expect(body.lines[1]!.level).toBe('ERROR');
expect(body.lines[1]!.fields).toEqual({ code: '500' });
});
it('rejects a non-zip upload with 400', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-imp-route-'));
const res = await importsRoute(home).request('/', { method: 'POST', body: Buffer.from('not a zip') });
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'BAD_REQUEST' });
});
it('rejects an empty upload with 400', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-imp-route-'));
const res = await importsRoute(home).request('/', { method: 'POST', body: Buffer.alloc(0) });
expect(res.status).toBe(400);
});
it('falls back to disk agent discovery when an imported bundle state omits agents', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-imp-route-'));
// Readable state.json, but no `agents` map (best-effort bundle). The main
// wire is present on disk, so the agent must still be discoverable.
const noAgents: Record<string, string> = {
'manifest.json': JSON.stringify({ sessionId: 'session_orig' }),
'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'demo', custom: {} }),
'agents/main/wire.jsonl': `${META}\n${PROMPT}\n`,
};
const importRes = await importsRoute(home).request('/?name=x.zip', { method: 'POST', body: await buildZip(noAgents) });
expect(importRes.status).toBe(200);
const importId = ((await importRes.json()) as { sessionId: string }).sessionId;
// Despite the empty state.agents, the wire route resolves `main` via disk.
const wireRes = await wireRoute(home).request(`/${importId}/wire?agent=main`);
expect(wireRes.status).toBe(200);
expect(((await wireRes.json()) as { records: unknown[] }).records.length).toBeGreaterThanOrEqual(1);
});
it('falls back to disk discovery when an imported agents map is type-corrupt', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-imp-route-'));
// state.json present, agents map non-empty but the entry is null — must not
// 500; the on-disk main wire should still be discoverable.
const corruptAgents: Record<string, string> = {
'manifest.json': JSON.stringify({ sessionId: 'session_orig' }),
'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'demo', agents: { main: null }, custom: {} }),
'agents/main/wire.jsonl': `${META}\n${PROMPT}\n`,
};
const importId = ((await (await importsRoute(home).request('/?name=x.zip', { method: 'POST', body: await buildZip(corruptAgents) })).json()) as { sessionId: string }).sessionId;
const wireRes = await wireRoute(home).request(`/${importId}/wire?agent=main`);
expect(wireRes.status).toBe(200);
expect(((await wireRes.json()) as { records: unknown[] }).records.length).toBeGreaterThanOrEqual(1);
});
it('sanitizes type-corrupt manifest fields so the session list cannot crash', async () => {
home = await mkdtemp(join(tmpdir(), 'vis-imp-route-'));
const corrupt: Record<string, string> = {
// workspaceDir / kimiCodeVersion are the wrong type — must not reach workDir.
'manifest.json': JSON.stringify({ sessionId: 'session_orig', workspaceDir: 123, kimiCodeVersion: 7, title: 'demo' }),
'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'demo', agents: { main: { homedir: '/o', type: 'main', parentAgentId: null } }, custom: {} }),
'agents/main/wire.jsonl': `${META}\n${PROMPT}\n`,
};
const importId = ((await (await importsRoute(home).request('/?name=x.zip', { method: 'POST', body: await buildZip(corrupt) })).json()) as { sessionId: string }).sessionId;
const body = (await (await sessionsRoute(home).request('/')).json()) as {
sessions: { sessionId: string; workDir: unknown; importMeta: { manifest: { workspaceDir?: unknown; kimiCodeVersion?: unknown; sessionId?: unknown } | null } | null }[];
};
const s = body.sessions.find((x) => x.sessionId === importId)!;
expect(typeof s.workDir).toBe('string'); // not the number 123
expect(s.workDir).toBe('');
expect(s.importMeta?.manifest?.workspaceDir).toBeUndefined(); // dropped
expect(s.importMeta?.manifest?.kimiCodeVersion).toBeUndefined(); // dropped
expect(s.importMeta?.manifest?.sessionId).toBe('session_orig'); // valid string kept
});
});

View file

@ -0,0 +1,78 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { buildSessionFixture } from '../fixtures/build';
import { logsRoute } from '../../src/routes/logs';
interface LogsBody {
available: { session: boolean; global: boolean };
lines: { message: string; level: string | null; fields: Record<string, string> }[];
}
describe('logs route (local sessions)', () => {
let cleanup: (() => Promise<void>) | null = null;
afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; });
it('reads the session log from the session dir and the global log from KIMI_CODE_HOME', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
// Per-session log lives under the session dir…
await mkdir(join(sessionDir, 'logs'), { recursive: true });
await writeFile(join(sessionDir, 'logs', 'kimi-code.log'), '2026-06-01T00:00:00.000Z INFO session boot k=v\n');
// …but the shared global log lives at <home>/logs/kimi-code.log, NOT under
// the session dir. Before the fix this was reported as unavailable.
await mkdir(join(home, 'logs'), { recursive: true });
await writeFile(join(home, 'logs', 'kimi-code.log'), '2026-06-01T00:00:01.000Z WARN global thing g=1\n');
const app = logsRoute(home);
const sessionRes = await app.request('/session_fixture/logs');
expect(sessionRes.status).toBe(200);
const sb = (await sessionRes.json()) as LogsBody;
expect(sb.available).toEqual({ session: true, global: true });
expect(sb.lines[0]!.message).toBe('session boot');
const globalRes = await app.request('/session_fixture/logs?which=global');
expect(globalRes.status).toBe(200);
const gb = (await globalRes.json()) as LogsBody;
expect(gb.lines[0]!.message).toBe('global thing');
expect(gb.lines[0]!.level).toBe('WARN');
expect(gb.lines[0]!.fields).toEqual({ g: '1' });
});
it('reports global unavailable for a local session with no home global log', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const res = await logsRoute(home).request('/session_fixture/logs');
expect(res.status).toBe(200);
expect(((await res.json()) as LogsBody).available.global).toBe(false);
});
it('discovers a rotated session log when the active file has rotated away', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await mkdir(join(sessionDir, 'logs'), { recursive: true });
// Only an archive exists — no active kimi-code.log.
await writeFile(join(sessionDir, 'logs', 'kimi-code.log.1'), '2026-06-01T00:00:00.000Z INFO rotated only r=1\n');
const res = await logsRoute(home).request('/session_fixture/logs');
const b = (await res.json()) as LogsBody;
expect(b.available.session).toBe(true);
expect(b.lines[0]!.message).toBe('rotated only');
});
it('concatenates rotated + active session logs oldest-first', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await mkdir(join(sessionDir, 'logs'), { recursive: true });
await writeFile(join(sessionDir, 'logs', 'kimi-code.log.2'), '2026-06-01T00:00:00.000Z INFO oldest\n');
await writeFile(join(sessionDir, 'logs', 'kimi-code.log.1'), '2026-06-01T00:00:01.000Z INFO middle\n');
await writeFile(join(sessionDir, 'logs', 'kimi-code.log'), '2026-06-01T00:00:02.000Z INFO newest\n');
const res = await logsRoute(home).request('/session_fixture/logs');
const b = (await res.json()) as LogsBody;
expect(b.lines.map((l) => l.message)).toEqual(['oldest', 'middle', 'newest']);
});
});

View file

@ -0,0 +1,97 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { buildSessionFixture } from '../fixtures/build';
import { tasksRoute } from '../../src/routes/tasks';
describe('tasks route', () => {
let cleanup: (() => Promise<void>) | null = null;
afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; });
// Tasks live under the spawning agent's homedir (<session>/agents/main/tasks),
// NOT the session root — seed there so the test mirrors real on-disk layout.
async function seed(sessionDir: string): Promise<void> {
const dir = join(sessionDir, 'agents', 'main', 'tasks');
await mkdir(join(dir, 'bash-12345678'), { recursive: true });
await writeFile(join(dir, 'bash-12345678.json'), JSON.stringify({
taskId: 'bash-12345678', kind: 'process', description: 'build',
command: 'pnpm build', pid: 7, exitCode: 0, status: 'completed',
detached: true, startedAt: 100, endedAt: 200,
}));
await writeFile(join(dir, 'bash-12345678', 'output.log'), 'line one\nline two\n');
}
it('GET /:id/tasks returns entries with output metadata', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await seed(sessionDir);
const app = tasksRoute(home);
const res = await app.request('/session_fixture/tasks');
expect(res.status).toBe(200);
const body = (await res.json()) as {
sessionId: string;
tasks: { task: { taskId: string }; agentId: string; outputSizeBytes: number; outputExists: boolean }[];
};
expect(body.sessionId).toBe('session_fixture');
expect(body.tasks).toHaveLength(1);
expect(body.tasks[0]!.task.taskId).toBe('bash-12345678');
expect(body.tasks[0]!.agentId).toBe('main');
expect(body.tasks[0]!.outputExists).toBe(true);
expect(body.tasks[0]!.outputSizeBytes).toBe('line one\nline two\n'.length);
});
it('GET /:id/tasks returns [] when there are no tasks', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = tasksRoute(home);
const res = await app.request('/session_fixture/tasks');
expect(res.status).toBe(200);
expect(((await res.json()) as { tasks: unknown[] }).tasks).toEqual([]);
});
it('GET /:id/tasks/:taskId/output pages by byte window', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
await seed(sessionDir);
const app = tasksRoute(home);
const res = await app.request('/session_fixture/tasks/bash-12345678/output?offset=0&limit=8');
expect(res.status).toBe(200);
const body = (await res.json()) as { content: string; size: number; eof: boolean; offset: number; nextOffset: number };
expect(body.content).toBe('line one');
expect(body.size).toBe(18);
expect(body.eof).toBe(false);
expect(body.offset).toBe(0);
expect(body.nextOffset).toBe(8);
});
it('GET output returns empty window for a task with no log', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = tasksRoute(home);
const res = await app.request('/session_fixture/tasks/bash-00000000/output');
expect(res.status).toBe(200);
expect((await res.json())).toMatchObject({ size: 0, content: '', eof: true });
});
it('rejects an unsafe task id with 400', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = tasksRoute(home);
const res = await app.request('/session_fixture/tasks/..%2Fescape/output');
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'BAD_REQUEST' });
});
it('returns 404 for a missing session', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = tasksRoute(home);
const res = await app.request('/no-such-session/tasks');
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'NOT_FOUND' });
});
});

View file

@ -18,6 +18,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@ -35,6 +36,7 @@
"tailwindcss": "^4.1.4",
"typescript": "6.0.2",
"vite": "^6.3.3",
"vite-plugin-singlefile": "^2.3.3"
"vite-plugin-singlefile": "^2.3.3",
"vitest": "4.1.4"
}
}

View file

@ -5,6 +5,11 @@ import type {
WireResponse,
ContextResponse,
AgentTreeResponse,
BackgroundTasksResponse,
TaskOutputResponse,
CronTasksResponse,
ImportResult,
LogsResponse,
ApiError,
} from './types';
@ -112,6 +117,48 @@ export const api = {
getAgentTree: (id: string) =>
get<AgentTreeResponse>(`/api/sessions/${enc(id)}/agents`),
/** Background tasks (process / agent / question) persisted under the
* session's `tasks/` directory, each with `output.log` metadata. */
getTasks: (id: string) =>
get<BackgroundTasksResponse>(`/api/sessions/${enc(id)}/tasks`),
/** A byte-window of a single task's `output.log`. */
getTaskOutput: (id: string, taskId: string, offset = 0, limit?: number) =>
get<TaskOutputResponse>(
`/api/sessions/${enc(id)}/tasks/${enc(taskId)}/output?offset=${offset}` +
(limit !== undefined ? `&limit=${limit}` : ''),
),
/** Cron jobs persisted under the session's `cron/` directory. */
getCron: (id: string) =>
get<CronTasksResponse>(`/api/sessions/${enc(id)}/cron`),
/** Parsed diagnostic log for a session (works for local and imported). */
getLogs: (id: string, which: 'session' | 'global' = 'session') =>
get<LogsResponse>(`/api/sessions/${enc(id)}/logs?which=${which}`),
/** Import a `/export-debug-zip` bundle. Sends the raw file as the body. */
importZip: async (file: File): Promise<ImportResult> => {
const headers: Record<string, string> = { accept: 'application/json' };
const token = authToken();
if (token !== null && token.length > 0) headers['authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/imports?name=${enc(file.name)}`, {
method: 'POST',
headers,
body: file,
});
if (!res.ok) {
let err: ApiError | null = null;
try {
err = (await res.json()) as ApiError;
} catch {
/* ignore */
}
throw new Error(err?.error ?? `HTTP ${res.status} ${res.statusText}`);
}
return (await res.json()) as ImportResult;
},
deleteSession: (id: string) => del<DeleteSessionResponse>(`/api/sessions/${enc(id)}`),
/** Open the session's on-disk folder in the OS file manager. Side

View file

@ -0,0 +1,335 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../../hooks/useSession';
import { useWire } from '../../hooks/useWire';
import {
analyzeWire,
type Analysis,
type StepNode,
type ToolCallNode,
type TurnNode,
} from '../../lib/analysis';
import type { WireEntry } from '../../types';
import { formatBytes } from '../shared/SizePreview';
import { formatDuration, formatTokens } from '../../util/time';
import { Pill } from '../shared/Pill';
interface TimelineTabProps {
sessionId: string;
}
/** Timeline tab the agent's execution folded into turns steps tool
* calls, with the derived metrics the flat record list does not surface:
* durations, per-turn token cost, context-window growth, cache-hit rate,
* tool latency, truncation, and idle gaps. All computed client-side from
* the same wire the Wire tab fetches. */
export function TimelineTab({ sessionId }: TimelineTabProps) {
const { data: detail } = useSession(sessionId);
const [agentId, setAgentId] = useState('main');
// Reset the selected agent when navigating to another session while this tab
// stays mounted; otherwise a previously-selected subagent would 404 against
// the new session (mirrors WireTab/ContextTab).
useEffect(() => {
setAgentId('main');
}, [sessionId]);
const { data: wire, isLoading, error } = useWire(sessionId, agentId);
const analysis = useMemo<Analysis | null>(() => {
if (!wire) return null;
return analyzeWire(wire.records as WireEntry[]);
}, [wire]);
const agents = detail?.agents ?? [];
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-3 border-b border-border bg-surface-1 px-3 py-2">
<label className="flex items-center gap-2 font-mono text-[11px] text-fg-2">
<span className="text-fg-3">agent</span>
<select
value={agentId}
onChange={(ev) => { setAgentId(ev.target.value); }}
className="border border-border bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-0 focus:border-border-strong focus:outline-none"
>
{agents.length === 0 ? <option value={agentId}>{agentId}</option> : null}
{agents.map((a) => (
<option key={a.agentId} value={a.agentId}>
{a.agentId} ({a.type})
</option>
))}
</select>
</label>
</div>
{isLoading ? (
<div className="p-6 font-mono text-[12px] text-fg-3">analyzing</div>
) : error ? (
<div className="p-6 font-mono text-[12px] text-[var(--color-sev-error)]">{error.message}</div>
) : analysis === null || analysis.summary.turnCount === 0 ? (
<div className="p-6 font-mono text-[12px] text-fg-3">no turns to analyze in this agent's wire</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<SummaryGrid analysis={analysis} />
<ContextSparkline analysis={analysis} />
<ConfigChanges analysis={analysis} />
<ToolStatsTable analysis={analysis} />
<IdleGaps analysis={analysis} />
<section className="mt-6">
<SectionTitle>turns · {analysis.turns.length}</SectionTitle>
<div className="mt-2 flex flex-col gap-2">
{analysis.turns.map((turn) => (
<TurnCard key={turn.index} turn={turn} />
))}
</div>
</section>
</div>
)}
</div>
);
}
function SectionTitle({ children }: { children: import('react').ReactNode }) {
return <h3 className="font-mono text-[11px] uppercase tracking-[0.12em] text-fg-3">{children}</h3>;
}
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
return (
<div className="border border-border bg-surface-0 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-fg-3">{label}</div>
<div className="mt-0.5 font-mono text-[14px] tabular" style={tone ? { color: tone } : undefined}>
{value}
</div>
</div>
);
}
function SummaryGrid({ analysis }: { analysis: Analysis }) {
const s = analysis.summary;
const hit = analysis.cache.hitRate;
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
<Stat label="turns" value={String(s.turnCount)} />
<Stat label="steps" value={String(s.stepCount)} />
<Stat label="tool calls" value={String(s.toolCallCount)} />
<Stat
label="tool errors"
value={String(s.toolErrorCount)}
tone={s.toolErrorCount > 0 ? 'var(--color-sev-error)' : undefined}
/>
<Stat label="total tokens" value={formatTokens(s.totalTokens)} />
<Stat label="peak context" value={formatTokens(s.peakContextTokens)} />
<Stat label="cache hit" value={hit === null ? '—' : `${(hit * 100).toFixed(0)}%`} />
<Stat label="active / wall" value={`${formatDuration(s.activeMs)} / ${formatDuration(s.wallClockMs)}`} />
</div>
);
}
function ContextSparkline({ analysis }: { analysis: Analysis }) {
const pts = analysis.contextSeries;
if (pts.length < 2) return null;
const peak = analysis.summary.peakContextTokens || 1;
const W = 600;
const H = 44;
const dx = W / (pts.length - 1);
const path = pts
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${(i * dx).toFixed(1)} ${(H - (p.contextTokens / peak) * H).toFixed(1)}`)
.join(' ');
return (
<section className="mt-6">
<SectionTitle>context-window fill over steps · peak {formatTokens(peak)}</SectionTitle>
<div className="mt-2 border border-border bg-surface-0 p-3">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="h-12 w-full">
<path d={path} fill="none" stroke="var(--color-cat-conversation)" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
<div className="mt-1 flex justify-between font-mono text-[10px] text-fg-3">
<span>step 1</span>
<span>step {pts.length}</span>
</div>
</div>
</section>
);
}
function ToolStatsTable({ analysis }: { analysis: Analysis }) {
if (analysis.toolStats.length === 0) return null;
return (
<section className="mt-6">
<SectionTitle>tool usage · {analysis.toolStats.length} distinct</SectionTitle>
<div className="mt-2 overflow-x-auto border border-border bg-surface-0">
<table className="w-full font-mono text-[11px]">
<thead>
<tr className="border-b border-border text-fg-3">
<Th align="left">tool</Th><Th>calls</Th><Th>errors</Th><Th>truncated</Th>
<Th>avg</Th><Th>max</Th><Th>output</Th>
</tr>
</thead>
<tbody>
{analysis.toolStats.map((t) => (
<tr key={t.name} className="border-b border-border/50">
<td className="px-2 py-1 text-fg-0">{t.name}</td>
<Td>{t.count}</Td>
<Td tone={t.errorCount > 0 ? 'var(--color-sev-error)' : undefined}>{t.errorCount}</Td>
<Td tone={t.truncatedCount > 0 ? 'var(--color-sev-warning)' : undefined}>{t.truncatedCount}</Td>
<Td>{formatDuration(t.avgMs)}</Td>
<Td>{formatDuration(t.maxMs)}</Td>
<Td>{formatBytes(t.totalOutputBytes)}</Td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function Th({ children, align = 'right' }: { children: import('react').ReactNode; align?: 'left' | 'right' }) {
return <th className={`px-2 py-1 font-normal ${align === 'left' ? 'text-left' : 'text-right tabular'}`}>{children}</th>;
}
function Td({ children, tone }: { children: import('react').ReactNode; tone?: string }) {
return <td className="px-2 py-1 text-right tabular text-fg-1" style={tone ? { color: tone } : undefined}>{children}</td>;
}
function ConfigChanges({ analysis }: { analysis: Analysis }) {
if (analysis.configChanges.length === 0) return null;
return (
<section className="mt-6">
<SectionTitle>config changes · {analysis.configChanges.length}</SectionTitle>
<div className="mt-2 flex flex-col gap-1">
{analysis.configChanges.map((c) => (
<div key={c.lineNo} className="flex flex-wrap items-center gap-2 border border-border bg-surface-0 px-3 py-1.5 font-mono text-[11px]">
<span className="text-fg-3 tabular">line {c.lineNo}</span>
{c.changed.map((ch) => (
<Pill key={ch.field} tone="config" variant="outline">
{ch.field}={ch.value}
</Pill>
))}
</div>
))}
</div>
</section>
);
}
function IdleGaps({ analysis }: { analysis: Analysis }) {
const gaps = analysis.idleGaps.slice(0, 5);
if (gaps.length === 0) return null;
return (
<section className="mt-6">
<SectionTitle>longest idle gaps</SectionTitle>
<div className="mt-2 flex flex-col gap-1">
{gaps.map((g, i) => (
<div key={i} className="flex items-center gap-3 border border-border bg-surface-0 px-3 py-1.5 font-mono text-[11px]">
<Pill tone={g.kind === 'between_turns' ? 'meta' : 'warning'} variant="outline">
{g.kind === 'between_turns' ? 'waiting' : 'in-turn'}
</Pill>
<span className="text-fg-0 tabular">{formatDuration(g.gapMs)}</span>
<span className="ml-auto text-fg-3 tabular">line {g.afterLineNo} {g.beforeLineNo}</span>
</div>
))}
</div>
</section>
);
}
function TurnCard({ turn }: { turn: TurnNode }) {
const [open, setOpen] = useState(turn.index === 0);
const totalTokens = turn.tokens.inputOther + turn.tokens.output + turn.tokens.inputCacheRead + turn.tokens.inputCacheCreation;
return (
<div className="border border-border bg-surface-0">
<button
type="button"
onClick={() => { setOpen((v) => !v); }}
className="flex w-full flex-wrap items-center gap-2 px-3 py-2 text-left hover:bg-surface-1"
>
<span className="text-fg-3">{open ? '▾' : '▸'}</span>
<Pill tone={turn.trigger === 'steer' ? 'turn' : 'conversation'} variant="outline">
turn {turn.index}{turn.trigger === 'steer' ? ' (steer)' : ''}
</Pill>
{turn.originKind && turn.originKind !== 'user' ? (
<Pill tone="meta" variant="outline">{turn.originKind}</Pill>
) : null}
{turn.cancelled ? <Pill tone="warning">cancelled</Pill> : null}
{turn.toolErrorCount > 0 ? <Pill tone="error">{turn.toolErrorCount} err</Pill> : null}
<span className="min-w-0 flex-1 truncate font-mono text-[12px] text-fg-1" title={turn.promptText}>
{turn.promptText || '(no prompt text)'}
</span>
<span className="flex shrink-0 items-center gap-3 font-mono text-[11px] text-fg-3 tabular">
<span>{turn.steps.length} steps</span>
<span>{turn.toolCallCount} tools</span>
<span title="total tokens processed this turn">{formatTokens(totalTokens)} tok</span>
<span title="active execution time">{formatDuration(turn.durationMs)}</span>
</span>
</button>
{open ? (
<div className="border-t border-border px-3 py-2">
{turn.waitBeforeMs !== undefined && turn.waitBeforeMs >= 1000 ? (
<div className="mb-2 font-mono text-[10px] text-fg-3">
waited {formatDuration(turn.waitBeforeMs)} before this turn
</div>
) : null}
<div className="flex flex-col gap-1.5">
{turn.steps.map((step) => (
<StepRow key={step.uuid} step={step} turnDurationMs={turn.durationMs} />
))}
</div>
</div>
) : null}
</div>
);
}
function StepRow({ step, turnDurationMs }: { step: StepNode; turnDurationMs?: number }) {
const widthPct = turnDurationMs && step.durationMs ? Math.max(2, (step.durationMs / turnDurationMs) * 100) : 0;
return (
<div className="border-l-2 pl-2" style={{ borderColor: step.isError ? 'var(--color-sev-error)' : 'var(--color-border)' }}>
<div className="flex flex-wrap items-center gap-2 font-mono text-[11px]">
<span className="text-fg-2">step {step.step}</span>
{step.finishReason ? (
<span className={step.isError ? 'text-[var(--color-sev-error)]' : 'text-fg-3'}>{step.finishReason}</span>
) : null}
<span className="text-fg-3 tabular" title="step wall-clock duration">{formatDuration(step.durationMs)}</span>
{step.llmFirstTokenLatencyMs !== undefined ? (
<span className="text-fg-3 tabular" title="time to first token">ttft {step.llmFirstTokenLatencyMs}ms</span>
) : null}
{step.contextTokens !== undefined ? (
<span className="text-fg-3 tabular" title="context-window fill after step">ctx {formatTokens(step.contextTokens)}</span>
) : null}
{step.content.thinkChars > 0 ? <span className="text-[var(--color-cat-meta)]" title="reasoning chars">💭 {step.content.thinkChars}</span> : null}
</div>
{widthPct > 0 ? (
<div className="mt-0.5 h-1 w-full bg-surface-2">
<div className="h-1" style={{ width: `${widthPct}%`, backgroundColor: step.isError ? 'var(--color-sev-error)' : 'var(--color-cat-conversation)' }} />
</div>
) : null}
{step.toolCalls.length > 0 ? (
<div className="mt-1 flex flex-col gap-0.5">
{step.toolCalls.map((tc) => (
<ToolRow key={tc.toolCallId} tc={tc} stepDurationMs={step.durationMs} />
))}
</div>
) : null}
</div>
);
}
function ToolRow({ tc, stepDurationMs }: { tc: ToolCallNode; stepDurationMs?: number }) {
const widthPct = stepDurationMs && tc.durationMs ? Math.max(2, (tc.durationMs / stepDurationMs) * 100) : 0;
return (
<div className="flex flex-wrap items-center gap-2 pl-3 font-mono text-[11px]">
<span className="text-[var(--color-cat-tools)]">{tc.name}</span>
<span className="text-fg-3 tabular" title="call → result elapsed">{formatDuration(tc.durationMs)}</span>
{tc.outputBytes !== undefined ? (
<span className="text-fg-3 tabular" title="result output size">{formatBytes(tc.outputBytes)}</span>
) : null}
{tc.isError ? <Pill tone="error" variant="outline">error</Pill> : null}
{tc.truncated ? <Pill tone="warning" variant="outline">truncated</Pill> : null}
{tc.resultLineNo === undefined ? <Pill tone="warning" variant="outline">no result</Pill> : null}
{widthPct > 0 ? (
<div className="ml-auto h-1 w-24 bg-surface-2">
<div className="h-1" style={{ width: `${widthPct}%`, backgroundColor: tc.isError ? 'var(--color-sev-error)' : 'var(--color-cat-tools)' }} />
</div>
) : null}
</div>
);
}

View file

@ -40,7 +40,11 @@ export function AppShell({ children }: AppShellProps) {
</header>
<div className="flex min-h-0 flex-1">
<SessionRail />
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
{/* min-w-0 lets the main column shrink below its content's intrinsic
width; without it a flex child defaults to min-width:auto and wide
tab content (e.g. the Timeline's flex-wrap rows) blows the layout
out horizontally instead of wrapping. */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">{children}</main>
</div>
</div>
);

View file

@ -0,0 +1,190 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { useMemo, useRef, useState } from 'react';
import { useLogs } from '../../hooks/useTasks';
import type { LogLine } from '../../types';
import { formatWallClock } from '../../util/time';
import { Pill, type PillTone } from '../shared/Pill';
interface LogsTabProps {
sessionId: string;
}
function levelTone(level: string | null): PillTone {
switch (level) {
case 'ERROR':
case 'FATAL':
return 'error';
case 'WARN':
case 'WARNING':
return 'warning';
case 'INFO':
return 'info';
case 'DEBUG':
case 'TRACE':
return 'meta';
default:
return 'neutral';
}
}
const LEVELS = ['ALL', 'ERROR', 'WARN', 'INFO', 'DEBUG'] as const;
type LevelFilter = (typeof LEVELS)[number];
function matchesLevel(line: LogLine, filter: LevelFilter): boolean {
if (filter === 'ALL') return true;
if (line.level === null) return false;
if (filter === 'WARN') return line.level === 'WARN' || line.level === 'WARNING';
if (filter === 'ERROR') return line.level === 'ERROR' || line.level === 'FATAL';
return line.level === filter;
}
/** Logs tab structured view of a session's diagnostic log. Works for both
* local sessions (whose dir holds `logs/kimi-code.log`) and imported bundles
* (which additionally may carry the global log). */
export function LogsTab({ sessionId }: LogsTabProps) {
const [which, setWhich] = useState<'session' | 'global'>('session');
const [level, setLevel] = useState<LevelFilter>('ALL');
const [search, setSearch] = useState('');
const { data, isLoading, error } = useLogs(sessionId, which);
const parentRef = useRef<HTMLDivElement>(null);
const lines = data?.lines ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return lines.filter((l) => {
if (!matchesLevel(l, level)) return false;
if (!q) return true;
return l.raw.toLowerCase().includes(q);
});
}, [lines, level, search]);
const virt = useVirtualizer({
count: filtered.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 24,
overscan: 20,
getItemKey: (i) => filtered[i]?.lineNo ?? i,
});
const available = data?.available ?? { session: false, global: false };
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-surface-1 px-3 py-2">
<div className="flex items-center gap-1 font-mono text-[11px]">
<SegBtn active={which === 'session'} onClick={() => { setWhich('session'); }} disabled={!available.session && !isLoading}>
session
</SegBtn>
<SegBtn active={which === 'global'} onClick={() => { setWhich('global'); }} disabled={!available.global}>
global
</SegBtn>
</div>
<label className="flex items-center gap-1.5 font-mono text-[11px] text-fg-2">
<span className="text-fg-3">level</span>
<select
value={level}
onChange={(e) => { setLevel(e.target.value as LevelFilter); }}
className="border border-border bg-surface-0 px-1 py-0.5 text-fg-1 focus:border-border-strong focus:outline-none"
>
{LEVELS.map((l) => (
<option key={l} value={l}>{l.toLowerCase()}</option>
))}
</select>
</label>
<input
type="text"
placeholder="search log (substring)"
value={search}
onChange={(e) => { setSearch(e.target.value); }}
className="w-64 border border-border bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-border-strong focus:outline-none"
/>
<span className="ml-auto font-mono text-[11px] text-fg-3 tabular">
{filtered.length} / {lines.length}
{data?.truncated ? ' · tail' : ''}
</span>
</div>
{isLoading ? (
<div className="p-6 font-mono text-[12px] text-fg-3">loading log</div>
) : error ? (
<div className="p-6 font-mono text-[12px] text-[var(--color-sev-error)]">{error.message}</div>
) : lines.length === 0 ? (
<div className="p-6 font-mono text-[12px] text-fg-3">
{which === 'global' && !available.global
? 'no global log in this bundle (export without --include-global-log)'
: 'no log available for this session'}
</div>
) : (
<div ref={parentRef} className="min-h-0 flex-1 overflow-y-auto">
{data?.truncated ? (
<div className="border-b border-[var(--color-sev-warning)] bg-[color-mix(in_oklab,var(--color-sev-warning)_8%,transparent)] px-3 py-1 font-mono text-[10px] text-[var(--color-sev-warning)]">
log is large showing the most recent {lines.length} lines
</div>
) : null}
<div style={{ height: virt.getTotalSize(), position: 'relative' }}>
{virt.getVirtualItems().map((vi) => {
const line = filtered[vi.index];
if (!line) return null;
return (
<div
key={vi.key}
data-index={vi.index}
ref={virt.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
<LogRow line={line} />
</div>
);
})}
</div>
</div>
)}
</div>
);
}
function SegBtn({ active, onClick, disabled, children }: { active: boolean; onClick: () => void; disabled?: boolean; children: import('react').ReactNode }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={[
'border px-2 py-0.5',
active ? 'border-[var(--color-cat-conversation)] text-fg-0' : 'border-border text-fg-2 hover:text-fg-0',
disabled ? 'opacity-40' : '',
].join(' ')}
>
{children}
</button>
);
}
function LogRow({ line }: { line: LogLine }) {
const fieldKeys = Object.keys(line.fields);
return (
<div className="flex items-start gap-2 border-b border-border/40 px-3 py-[3px] font-mono text-[11px] hover:bg-surface-1">
<span className="w-[68px] shrink-0 text-fg-3 tabular" title={line.time ?? ''}>
{line.time ? formatWallClock(Date.parse(line.time)) : '—'}
</span>
<span className="w-[52px] shrink-0">
{line.level ? (
<Pill tone={levelTone(line.level)} variant="outline">{line.level}</Pill>
) : null}
</span>
<span className="min-w-0 flex-1 break-words text-fg-1">
{line.message}
{fieldKeys.length > 0 ? (
<span className="ml-2 text-fg-3">
{fieldKeys.map((k) => (
<span key={k} className="mr-2">
<span className="text-fg-2">{k}</span>=<span className="text-[var(--color-sev-info)]">{line.fields[k]}</span>
</span>
))}
</span>
) : null}
</span>
</div>
);
}

View file

@ -40,9 +40,22 @@ export function SessionCard({ session, onDelete, deleting }: SessionCardProps) {
<div className="flex min-w-0 items-center gap-2">
<span
className="inline-block h-[7px] w-[7px] shrink-0 rounded-full"
style={{ backgroundColor: 'var(--color-fg-3)' }}
style={{ backgroundColor: session.imported ? 'var(--color-cat-subagent)' : 'var(--color-fg-3)' }}
/>
<span className="shrink-0 font-mono text-[12px] text-fg-0">{shortId}</span>
{session.imported ? (
<span
className="shrink-0 border px-1 py-0 font-mono text-[9px] uppercase tracking-[0.08em]"
style={{ borderColor: 'var(--color-cat-subagent)', color: 'var(--color-cat-subagent)' }}
title={
session.importMeta?.originalName
? `imported from ${session.importMeta.originalName}`
: 'imported debug bundle'
}
>
imported
</span>
) : null}
</div>
<span className="shrink-0 font-mono text-[10.5px] text-fg-3 tabular">
{formatRelativeTime(session.updatedAt)}
@ -60,6 +73,11 @@ export function SessionCard({ session, onDelete, deleting }: SessionCardProps) {
{subagentCount}sub
</span>
) : null}
{session.imported && session.importMeta?.manifest?.kimiCodeVersion ? (
<span className="tabular text-fg-3" title="kimi-code version that produced this bundle">
v{session.importMeta.manifest.kimiCodeVersion}
</span>
) : null}
{session.health !== 'ok' ? (
<span className="tabular text-[var(--color-sev-error)]">
{session.health}

View file

@ -1,4 +1,6 @@
import type { SessionSortKey, HealthFilter } from './SessionRail';
import { useRef } from 'react';
import type { SessionSortKey, HealthFilter, SourceFilter } from './SessionRail';
interface SessionFilterProps {
search: string;
@ -7,8 +9,13 @@ interface SessionFilterProps {
onSortChange: (v: SessionSortKey) => void;
healthFilter: HealthFilter;
onHealthChange: (v: HealthFilter) => void;
sourceFilter: SourceFilter;
onSourceChange: (v: SourceFilter) => void;
totalCount: number;
filteredCount: number;
importedCount: number;
onImport: (file: File) => void;
importing: boolean;
}
const SORT_OPTIONS: { value: SessionSortKey; label: string }[] = [
@ -26,6 +33,12 @@ const HEALTH_OPTIONS: { value: HealthFilter; label: string }[] = [
{ value: 'missing_main_wire', label: 'no wire' },
];
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'all' },
{ value: 'local', label: 'local' },
{ value: 'imported', label: 'imported' },
];
export function SessionFilter({
search,
onSearchChange,
@ -33,11 +46,42 @@ export function SessionFilter({
onSortChange,
healthFilter,
onHealthChange,
sourceFilter,
onSourceChange,
totalCount,
filteredCount,
importedCount,
onImport,
importing,
}: SessionFilterProps) {
const fileInput = useRef<HTMLInputElement>(null);
return (
<div className="border-b border-border bg-surface-1 px-3 py-2">
<div className="mb-2 flex items-center gap-2">
<input
ref={fileInput}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onImport(file);
e.target.value = '';
}}
/>
<button
type="button"
disabled={importing}
onClick={() => fileInput.current?.click()}
className="flex items-center gap-1.5 border border-border bg-surface-0 px-2 py-1 font-mono text-[11px] text-fg-1 hover:border-border-strong hover:text-fg-0 disabled:opacity-50"
title="Import a /export-debug-zip bundle a user sent you"
>
{importing ? 'importing…' : '⬆ import debug zip'}
</button>
{importedCount > 0 ? (
<span className="font-mono text-[10px] text-fg-3 tabular">{importedCount} imported</span>
) : null}
</div>
<div className="relative">
<input
type="text"
@ -62,6 +106,20 @@ export function SessionFilter({
))}
</select>
</label>
<label className="flex items-center gap-1.5 font-mono text-[10.5px] text-fg-2">
<span className="text-fg-3">source</span>
<select
value={sourceFilter}
onChange={(e) => { onSourceChange(e.target.value as SourceFilter); }}
className="flex-1 border border-border bg-surface-0 px-1 py-0.5 text-fg-1 focus:border-border-strong focus:outline-none"
>
{SOURCE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1.5 font-mono text-[10.5px] text-fg-2">
<span className="text-fg-3">health</span>
<select
@ -76,11 +134,11 @@ export function SessionFilter({
))}
</select>
</label>
</div>
<div className="mt-2 flex items-center justify-end">
<span className="font-mono text-[10px] text-fg-3 tabular">
{filteredCount} / {totalCount}
</span>
<div className="flex items-center justify-end">
<span className="font-mono text-[10px] text-fg-3 tabular">
{filteredCount} / {totalCount}
</span>
</div>
</div>
</div>
);

View file

@ -1,13 +1,14 @@
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useDeleteSession, useSessions } from '../../hooks/useSession';
import { useDeleteSession, useImportZip, useSessions } from '../../hooks/useSession';
import type { SessionSummary, SessionHealth } from '../../types';
import { SessionCard } from './SessionCard';
import { SessionFilter } from './SessionFilter';
export type SessionSortKey = 'recent' | 'oldest' | 'most_records' | 'most_subagents';
export type HealthFilter = 'all' | SessionHealth;
export type SourceFilter = 'all' | 'local' | 'imported';
function workspaceKey(s: SessionSummary): string {
if (!s.workDir) return '(no workspace)';
@ -30,29 +31,45 @@ function sortSessions(sessions: readonly SessionSummary[], key: SessionSortKey):
export function SessionRail() {
const { data, isLoading, error } = useSessions();
const deleteSession = useDeleteSession();
const importZip = useImportZip();
const navigate = useNavigate();
const { sessionId } = useParams<{ sessionId: string }>();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SessionSortKey>('recent');
const [healthFilter, setHealthFilter] = useState<HealthFilter>('all');
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
const filtered = useMemo(() => {
if (!data) return [];
const q = search.trim().toLowerCase();
return data.filter((s) => {
if (healthFilter !== 'all' && s.health !== healthFilter) return false;
if (sourceFilter === 'local' && s.imported) return false;
if (sourceFilter === 'imported' && !s.imported) return false;
if (!q) return true;
const hay = [
s.sessionId,
s.title ?? '',
s.lastPrompt ?? '',
s.workDir ?? '',
s.importMeta?.originalName ?? '',
]
.join(' ')
.toLowerCase();
return hay.includes(q);
});
}, [data, search, healthFilter]);
}, [data, search, healthFilter, sourceFilter]);
const importedCount = useMemo(() => (data ?? []).filter((s) => s.imported).length, [data]);
async function handleImport(file: File) {
try {
const result = await importZip.mutateAsync(file);
void navigate(`/sessions/${result.sessionId}`);
} catch (importError) {
window.alert(`Import failed: ${importError instanceof Error ? importError.message : String(importError)}`);
}
}
const grouped = useMemo(() => {
if (sortKey !== 'recent') return null;
@ -107,8 +124,13 @@ export function SessionRail() {
onSortChange={setSortKey}
healthFilter={healthFilter}
onHealthChange={setHealthFilter}
sourceFilter={sourceFilter}
onSourceChange={setSourceFilter}
totalCount={data?.length ?? 0}
filteredCount={filtered.length}
importedCount={importedCount}
onImport={(file) => { void handleImport(file); }}
importing={importZip.isPending}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading ? (

View file

@ -1,5 +1,6 @@
import { useMemo } from 'react';
import type { ImportInfo } from '../../types';
import { formatAbsoluteTime, formatRelativeTime } from '../../util/time';
import { CopyButton } from '../shared/CopyButton';
import { JsonViewer } from '../shared/JsonViewer';
@ -7,6 +8,7 @@ import { Pill } from '../shared/Pill';
interface StateTabProps {
state: unknown;
importMeta?: ImportInfo | null;
}
interface StateJsonShape {
@ -25,7 +27,7 @@ interface StateJsonShape {
* (title / lastPrompt / created / updated / agent count). Below that, the
* full JSON is shown via the shared JsonViewer so any custom fields the
* upstream writer adds remain readable without code changes. */
export function StateTab({ state }: StateTabProps) {
export function StateTab({ state, importMeta }: StateTabProps) {
const s = useMemo<StateJsonShape>(() => {
return (state ?? {}) as StateJsonShape;
}, [state]);
@ -37,6 +39,8 @@ export function StateTab({ state }: StateTabProps) {
return (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{importMeta ? <ManifestCard meta={importMeta} /> : null}
<div className="flex items-center justify-between">
<div className="font-mono text-[11px] uppercase tracking-[0.12em] text-fg-3">
state.json
@ -154,6 +158,51 @@ function Card({ label, children }: { label: string; children: import('react').Re
);
}
/** Export-bundle provenance, shown above state.json for imported sessions. */
function ManifestCard({ meta }: { meta: ImportInfo }) {
const m = meta.manifest;
const candidates: [string, string | undefined][] = [
['original session', m?.sessionId],
['kimi-code version', m?.kimiCodeVersion],
['wire protocol', m?.wireProtocolVersion],
['os', m?.os],
['node', m?.nodejsVersion],
['install source', m?.installSource],
['workspace', m?.workspaceDir],
['exported at', m?.exportedAt ? `${formatAbsoluteTime(Date.parse(m.exportedAt))} (${formatRelativeTime(Date.parse(m.exportedAt))})` : undefined],
['first activity', m?.sessionFirstActivity ? formatAbsoluteTime(Date.parse(m.sessionFirstActivity)) : undefined],
['last activity', m?.sessionLastActivity ? formatAbsoluteTime(Date.parse(m.sessionLastActivity)) : undefined],
['imported at', `${formatAbsoluteTime(Date.parse(meta.importedAt))} (${formatRelativeTime(Date.parse(meta.importedAt))})`],
['original file', meta.originalName ?? undefined],
];
const rows = candidates
.filter((r): r is [string, string] => typeof r[1] === 'string' && r[1].length > 0)
.map(([label, value]) => ({ label, value }));
return (
<section className="mb-5 border border-[var(--color-cat-subagent)] bg-[color-mix(in_oklab,var(--color-cat-subagent)_8%,transparent)] p-3">
<div className="flex items-center gap-2">
<Pill tone="subagent" variant="outline">imported bundle</Pill>
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-fg-3">manifest</span>
<span className="ml-auto"><CopyButton value={JSON.stringify(meta, null, 2)} label="copy manifest" /></span>
</div>
<div className="mt-2 grid grid-cols-1 gap-x-6 gap-y-1 md:grid-cols-2">
{rows.map((r) => (
<div key={r.label} className="flex items-baseline gap-2 font-mono text-[11px]">
<span className="w-32 shrink-0 text-[10px] uppercase tracking-[0.1em] text-fg-3">{r.label}</span>
<span className="min-w-0 break-all text-fg-1">{r.value}</span>
</div>
))}
</div>
{m === null ? (
<div className="mt-2 font-mono text-[11px] text-[var(--color-sev-warning)]">
manifest.json was missing or unreadable in this bundle
</div>
) : null}
</section>
);
}
function TsValue({ ms, raw }: { ms: number | null; raw: string | undefined }) {
if (ms === null) {
return raw !== undefined && raw !== '' ? (

View file

@ -0,0 +1,96 @@
import type { CronTask } from '../../types';
import { formatAbsoluteTime, formatRelativeTime } from '../../util/time';
import { useCron } from '../../hooks/useTasks';
import { CopyButton } from '../shared/CopyButton';
import { Pill } from '../shared/Pill';
interface CronTabProps {
sessionId: string;
}
/** Cron tab — scheduled prompts persisted under the session's `cron/`
* directory. Like background tasks, none of this is in the wire, so it is
* the only place to see what a session has scheduled. */
export function CronTab({ sessionId }: CronTabProps) {
const { data, isLoading, error } = useCron(sessionId);
if (isLoading) {
return <div className="p-6 font-mono text-[12px] text-fg-3">loading cron</div>;
}
if (error) {
return (
<div className="p-6 font-mono text-[12px] text-[var(--color-sev-error)]">
{error.message}
</div>
);
}
const cron = data?.cron ?? [];
return (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="font-mono text-[11px] uppercase tracking-[0.12em] text-fg-3">
cron jobs{cron.length > 0 ? ` · ${cron.length}` : ''}
</div>
{cron.length === 0 ? (
<div className="mt-3 border border-border bg-surface-0 px-3 py-6 text-center font-mono text-[12px] text-fg-3">
no cron jobs were scheduled in this session
</div>
) : (
<div className="mt-3 flex flex-col gap-2">
{cron.map((job) => (
<CronCard key={job.id} job={job} />
))}
</div>
)}
</div>
);
}
function CronCard({ job }: { job: CronTask }) {
// `recurring` is undefined/true → recurring by convention; false → one-shot.
const oneShot = job.recurring === false;
return (
<div className="border border-border bg-surface-0">
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
<Pill tone={oneShot ? 'ephemeral' : 'lifecycle'} variant="outline">
{oneShot ? 'one-shot' : 'recurring'}
</Pill>
<code className="font-mono text-[12px] text-fg-0">{job.cron}</code>
<span className="font-mono text-[11px] text-fg-3">{job.id}</span>
<CopyButton value={job.id} />
<span
className="ml-auto font-mono text-[11px] text-fg-3 tabular"
title={formatAbsoluteTime(job.createdAt)}
>
created {formatRelativeTime(job.createdAt)}
</span>
</div>
<div className="px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.1em] text-fg-3">prompt</div>
<div className="mt-1 whitespace-pre-wrap break-words font-mono text-[12px] text-fg-1">
{job.prompt}
</div>
</div>
<div className="grid grid-cols-1 gap-x-6 gap-y-1 border-t border-border px-3 py-2 md:grid-cols-2">
<Field label="lastFiredAt">
{job.lastFiredAt === undefined ? (
<span className="text-fg-3">(never fired)</span>
) : (
<span title={formatAbsoluteTime(job.lastFiredAt)}>
{formatAbsoluteTime(job.lastFiredAt)} ({formatRelativeTime(job.lastFiredAt)})
</span>
)}
</Field>
<Field label="createdAt">{formatAbsoluteTime(job.createdAt)}</Field>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: import('react').ReactNode }) {
return (
<div className="flex items-baseline gap-2 font-mono text-[12px]">
<span className="w-28 shrink-0 text-[10px] uppercase tracking-[0.1em] text-fg-3">{label}</span>
<span className="min-w-0 break-words text-fg-1">{children}</span>
</div>
);
}

View file

@ -0,0 +1,270 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api';
import type { BackgroundTaskEntry, BackgroundTaskInfo, BackgroundTaskStatus } from '../../types';
import { formatAbsoluteTime, formatRelativeTime } from '../../util/time';
import { useTasks } from '../../hooks/useTasks';
import { CopyButton } from '../shared/CopyButton';
import { JsonViewer } from '../shared/JsonViewer';
import { formatBytes } from '../shared/SizePreview';
import { Pill, type PillTone } from '../shared/Pill';
interface TasksTabProps {
sessionId: string;
}
const STATUS_TONE: Record<BackgroundTaskStatus, PillTone> = {
running: 'info',
completed: 'success',
failed: 'error',
timed_out: 'warning',
killed: 'warning',
lost: 'neutral',
};
function kindTone(kind: BackgroundTaskInfo['kind']): PillTone {
if (kind === 'agent') return 'subagent';
if (kind === 'question') return 'approval';
return 'tools';
}
/** Tasks tab background tasks (bash processes, subagents, pending
* questions) persisted under the session's `tasks/` directory, plus their
* `output.log`. None of this is reconstructable from the wire, so it is the
* only place to inspect what a session spawned in the background. */
export function TasksTab({ sessionId }: TasksTabProps) {
const { data, isLoading, error } = useTasks(sessionId);
if (isLoading) {
return <div className="p-6 font-mono text-[12px] text-fg-3">loading tasks</div>;
}
if (error) {
return (
<div className="p-6 font-mono text-[12px] text-[var(--color-sev-error)]">
{error.message}
</div>
);
}
const tasks = data?.tasks ?? [];
return (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="font-mono text-[11px] uppercase tracking-[0.12em] text-fg-3">
background tasks{tasks.length > 0 ? ` · ${tasks.length}` : ''}
</div>
{tasks.length === 0 ? (
<div className="mt-3 border border-border bg-surface-0 px-3 py-6 text-center font-mono text-[12px] text-fg-3">
no background tasks were persisted for this session
</div>
) : (
<div className="mt-3 flex flex-col gap-2">
{tasks.map((entry) => (
<TaskCard key={entry.task.taskId} sessionId={sessionId} entry={entry} />
))}
</div>
)}
</div>
);
}
function TaskCard({ sessionId, entry }: { sessionId: string; entry: BackgroundTaskEntry }) {
const { task } = entry;
const [showLog, setShowLog] = useState(false);
const [showRaw, setShowRaw] = useState(false);
const duration =
task.endedAt !== null && task.endedAt !== undefined
? task.endedAt - task.startedAt
: null;
return (
<div className="border border-border bg-surface-0">
{/* Header line */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
<Pill tone={kindTone(task.kind)} variant="outline">{task.kind}</Pill>
<Pill tone={STATUS_TONE[task.status]}>{task.status}</Pill>
<span className="font-mono text-[12px] text-fg-0">{task.taskId}</span>
<CopyButton value={task.taskId} />
{entry.agentId !== 'main' ? (
<Pill tone="subagent" variant="outline" title="the agent that spawned this task">
{entry.agentId}
</Pill>
) : null}
{task.detached === false ? (
<Pill tone="warning" variant="outline">foreground</Pill>
) : null}
<span className="ml-auto font-mono text-[11px] text-fg-3 tabular" title={formatAbsoluteTime(task.startedAt)}>
started {formatRelativeTime(task.startedAt)}
</span>
</div>
{/* Body fields */}
<div className="grid grid-cols-1 gap-x-6 gap-y-1 px-3 py-2 md:grid-cols-2">
<Field label="description">{task.description || <Dim>(none)</Dim>}</Field>
{task.kind === 'process' ? (
<>
<Field label="command"><code className="break-all">{task.command}</code></Field>
<Field label="pid">{task.pid}</Field>
<Field label="exitCode">
{task.exitCode ?? <Dim>(running)</Dim>}
</Field>
</>
) : null}
{task.kind === 'agent' ? (
<>
<Field label="agentId">
{task.agentId ? (
<Link
to={`/sessions/${sessionId}/agents/${task.agentId}`}
className="text-[var(--color-cat-subagent)] underline-offset-2 hover:underline"
title="open this subagent's wire"
>
{task.agentId}
</Link>
) : (
<Dim>(none)</Dim>
)}
</Field>
<Field label="subagentType">{task.subagentType ?? <Dim>(none)</Dim>}</Field>
</>
) : null}
{task.kind === 'question' ? (
<>
<Field label="questionCount">{task.questionCount}</Field>
<Field label="toolCallId">{task.toolCallId ?? <Dim>(none)</Dim>}</Field>
</>
) : null}
<Field label="duration">
{duration === null ? <Dim>(unfinished)</Dim> : `${duration} ms`}
</Field>
{task.timeoutMs !== undefined ? (
<Field label="timeoutMs">{task.timeoutMs}</Field>
) : null}
{task.stopReason ? <Field label="stopReason">{task.stopReason}</Field> : null}
<Field label="endedAt">
{task.endedAt === null || task.endedAt === undefined ? (
<Dim>(running)</Dim>
) : (
<span title={formatAbsoluteTime(task.endedAt)}>{formatRelativeTime(task.endedAt)}</span>
)}
</Field>
</div>
{/* Toggles */}
<div className="flex items-center gap-3 border-t border-border px-3 py-1.5">
<button
type="button"
onClick={() => { setShowLog((v) => !v); }}
className="font-mono text-[11px] text-fg-2 hover:text-fg-0"
disabled={!entry.outputExists}
title={entry.outputExists ? 'view output.log' : 'no output.log for this task'}
>
{showLog ? '▾' : '▸'} output.log{' '}
<span className="text-fg-3">
{entry.outputExists ? formatBytes(entry.outputSizeBytes) : '(none)'}
</span>
</button>
<button
type="button"
onClick={() => { setShowRaw((v) => !v); }}
className="ml-auto font-mono text-[11px] text-fg-3 hover:text-fg-1"
>
{showRaw ? 'hide raw' : 'raw json'}
</button>
</div>
{showLog && entry.outputExists ? (
<TaskOutput sessionId={sessionId} taskId={task.taskId} />
) : null}
{showRaw ? (
<div className="border-t border-border bg-surface-0 px-3 py-2">
<JsonViewer value={task} defaultOpenDepth={2} />
</div>
) : null}
</div>
);
}
function TaskOutput({ sessionId, taskId }: { sessionId: string; taskId: string }) {
// Progressive byte-window paging: fetch the first window on mount, then
// append subsequent windows on demand via the server-provided exact
// `nextOffset` cursor. Keeps arbitrarily large logs readable in full.
const [content, setContent] = useState('');
const [cursor, setCursor] = useState(0);
const [size, setSize] = useState(0);
const [eof, setEof] = useState(false);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [started, setStarted] = useState(false);
const loadFrom = useCallback(
async (offset: number) => {
setLoading(true);
setErr(null);
try {
const w = await api.getTaskOutput(sessionId, taskId, offset);
setContent((prev) => (offset === 0 ? w.content : prev + w.content));
setCursor(w.nextOffset);
setSize(w.size);
setEof(w.eof);
} catch (error) {
setErr(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
},
[sessionId, taskId],
);
useEffect(() => {
if (started) return;
setStarted(true);
void loadFrom(0);
}, [started, loadFrom]);
return (
<div className="border-t border-border bg-[var(--color-surface-0)]">
<div className="flex items-center gap-2 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.1em] text-fg-3">
<span>output.log</span>
<span className="tabular">
{formatBytes(Math.min(cursor, size))} / {formatBytes(size)}
</span>
{!eof && cursor > 0 ? (
<span className="text-[var(--color-sev-warning)]">· more below</span>
) : null}
<span className="ml-auto"><CopyButton value={content} label="copy" /></span>
</div>
{err !== null ? (
<div className="border-t border-border px-3 py-2 font-mono text-[11px] text-[var(--color-sev-error)]">
{err}
</div>
) : null}
<pre className="max-h-[480px] overflow-auto whitespace-pre-wrap break-words border-t border-border px-3 py-2 font-mono text-[11px] leading-[1.5] text-fg-1">
{content || (loading ? 'loading log…' : '(empty)')}
</pre>
{!eof && cursor > 0 ? (
<button
type="button"
onClick={() => { void loadFrom(cursor); }}
disabled={loading}
className="w-full border-t border-border px-3 py-1.5 font-mono text-[11px] text-fg-2 hover:bg-surface-2 hover:text-fg-0 disabled:opacity-50"
>
{loading ? 'loading…' : `load more (${formatBytes(size - cursor)} remaining)`}
</button>
) : null}
</div>
);
}
function Field({ label, children }: { label: string; children: import('react').ReactNode }) {
return (
<div className="flex items-baseline gap-2 font-mono text-[12px]">
<span className="w-28 shrink-0 text-[10px] uppercase tracking-[0.1em] text-fg-3">{label}</span>
<span className="min-w-0 break-words text-fg-1">{children}</span>
</div>
);
}
function Dim({ children }: { children: import('react').ReactNode }) {
return <span className="text-fg-3">{children}</span>;
}

View file

@ -21,6 +21,10 @@ const SEV_COLOR: Record<IssueSeverity, string> = {
const KIND_LABEL: Record<Issue['kind'], string> = {
orphan_tool_call: 'orphan tool.call',
missing_tool_result: 'missing tool.result',
tool_error: 'tool error',
tool_truncated: 'tool output truncated',
model_filtered: 'response filtered',
model_max_tokens: 'hit max_tokens',
incomplete_step: 'incomplete step',
incomplete_compaction: 'incomplete compaction',
active_plan_mode: 'plan mode active',

View file

@ -1,7 +1,7 @@
import { memo, useCallback } from 'react';
import type { WireEntry } from '../../types';
import { formatWallClock } from '../../util/time';
import { formatDuration, formatWallClock } from '../../util/time';
import { TypeBadge } from './TypeBadge';
import { renderHeadline } from './WireHeadline';
import { WireRowDetail } from './WireRowDetail';
@ -15,6 +15,8 @@ export interface PairHint {
kind: 'call' | 'result';
callLineNo: number | null;
resultLineNo: number | null;
/** result.time call.time, when both records carry a timestamp. */
durationMs: number | null;
}
interface WireRowProps {
@ -126,31 +128,45 @@ function PairIndicator({
orphan ? 'text-[var(--color-sev-error)]' : 'text-[var(--color-cat-tools)] hover:text-fg-0'
}`;
// Show the call→result elapsed time on whichever row has its partner.
const duration =
pair.durationMs !== null ? (
<span className="font-mono text-[10px] text-fg-3 tabular" title="tool.call → tool.result elapsed">
{formatDuration(pair.durationMs)}
</span>
) : null;
if (orphan || target === null || onJumpTo === undefined) {
return (
<span className={className} title={title}>
{label}
<span className="flex items-center gap-1.5">
{duration}
<span className={className} title={title}>
{label}
</span>
</span>
);
}
return (
<span
role="link"
tabIndex={0}
className={`${className} cursor-pointer`}
title={title}
onClick={(e) => {
e.stopPropagation();
onJumpTo(target);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
<span className="flex items-center gap-1.5">
{duration}
<span
role="link"
tabIndex={0}
className={`${className} cursor-pointer`}
title={title}
onClick={(e) => {
e.stopPropagation();
onJumpTo(target);
}
}}
>
{label}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
onJumpTo(target);
}
}}
>
{label}
</span>
</span>
);
}

View file

@ -11,27 +11,35 @@ import { WireRow, type PairHint } from './WireRow';
interface PairRecord {
callLineNo: number | null;
resultLineNo: number | null;
callTime: number | null;
resultTime: number | null;
}
/** Scan all entries and pair every `tool.call` with its `tool.result`
* by `toolCallId`. Used to render the inline "→ #N" / "← #N" cross-
* references and to drive the hover-pair highlight. */
* references, the callresult duration, and to drive the hover-pair
* highlight. */
function computePairMap(entries: readonly WireEntry[]): Map<string, PairRecord> {
const map = new Map<string, PairRecord>();
const ensure = (id: string): PairRecord => {
const existing = map.get(id);
if (existing) return existing;
const fresh: PairRecord = { callLineNo: null, resultLineNo: null };
const fresh: PairRecord = { callLineNo: null, resultLineNo: null, callTime: null, resultTime: null };
map.set(id, fresh);
return fresh;
};
for (const entry of entries) {
if (entry.data.type !== 'context.append_loop_event') continue;
const ev = entry.data.event;
const time = entry.data.time ?? null;
if (ev.type === 'tool.call') {
ensure(ev.toolCallId).callLineNo = entry.lineNo;
const rec = ensure(ev.toolCallId);
rec.callLineNo = entry.lineNo;
rec.callTime = time;
} else if (ev.type === 'tool.result') {
ensure(ev.toolCallId).resultLineNo = entry.lineNo;
const rec = ensure(ev.toolCallId);
rec.resultLineNo = entry.lineNo;
rec.resultTime = time;
}
}
return map;
@ -43,11 +51,14 @@ function pairInfoFor(record: AgentRecord, map: Map<string, PairRecord>): PairHin
if (ev.type !== 'tool.call' && ev.type !== 'tool.result') return undefined;
const entry = map.get(ev.toolCallId);
if (entry === undefined) return undefined;
const durationMs =
entry.callTime !== null && entry.resultTime !== null ? entry.resultTime - entry.callTime : null;
return {
toolCallId: ev.toolCallId,
kind: ev.type === 'tool.call' ? 'call' : 'result',
callLineNo: entry.callLineNo,
resultLineNo: entry.resultLineNo,
durationMs,
};
}

View file

@ -299,6 +299,13 @@ export function LoopEventDetail({ event }: { event: LoopRecordedEvent }) {
{String(isError)}
</span>
</FieldRow>
{event.result.truncated === true ? (
<FieldRow label="truncated">
<span className="text-[var(--color-sev-warning)]">
true · output was paged or dropped before the model saw it
</span>
</FieldRow>
) : null}
{event.result.message !== undefined ? (
<FieldRow label="message" wide>
<pre className="whitespace-pre-wrap break-words text-fg-1">

View file

@ -30,3 +30,14 @@ export function useDeleteSession() {
},
});
}
/** Import a debug zip; refreshes the session list on success. */
export function useImportZip() {
const qc = useQueryClient();
return useMutation({
mutationFn: (file: File) => api.importZip(file),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['sessions'] });
},
});
}

View file

@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';
/** Background tasks for a session (process / agent / question), with
* `output.log` size metadata per task. */
export function useTasks(sessionId: string | undefined) {
return useQuery({
queryKey: ['tasks', sessionId] as const,
queryFn: () => api.getTasks(sessionId!),
enabled: !!sessionId,
});
}
/** Cron jobs scheduled within a session. */
export function useCron(sessionId: string | undefined) {
return useQuery({
queryKey: ['cron', sessionId] as const,
queryFn: () => api.getCron(sessionId!),
enabled: !!sessionId,
});
}
/** Parsed diagnostic log for a session (session or global). */
export function useLogs(sessionId: string | undefined, which: 'session' | 'global') {
return useQuery({
queryKey: ['logs', sessionId, which] as const,
queryFn: () => api.getLogs(sessionId!, which),
enabled: !!sessionId,
});
}

View file

@ -0,0 +1,483 @@
// apps/vis/web/src/lib/analysis.ts
//
// Fold a flat wire timeline into the agent's natural execution structure —
// turns → steps → tool calls — and derive the metrics a data-analysis view
// needs but the raw record list does not surface:
// - per-turn / per-step / per-tool wall-clock duration (from record `time`)
// - per-turn token cost (sum of step usages) and cache-hit rate
// - context-window fill over time (mirrors agent-core's snapshot formula)
// - tool-result truncation / size / error flags
// - tool usage stats (count, error rate, latency)
// - idle gaps (large wall-clock gaps between records → waiting)
//
// Pure: consumes the same `WireEntry[]` the Wire tab already fetches, so the
// Timeline view needs no extra server round-trip.
import type { TokenUsage, WireEntry } from '../types';
export interface ContentSummary {
textChars: number;
thinkChars: number;
}
export interface ToolCallNode {
callLineNo: number;
toolCallId: string;
name: string;
description?: string;
callTime?: number;
resultLineNo?: number;
resultTime?: number;
/** resultTime callTime, when both are known. */
durationMs?: number;
isError?: boolean;
truncated?: boolean;
/** Approximate byte size of the tool result output. */
outputBytes?: number;
/** Optional human-readable side-channel message on the result. */
resultMessage?: string;
}
export interface StepNode {
uuid: string;
step: number;
turnId: string;
beginLineNo: number;
beginTime?: number;
endLineNo?: number;
endTime?: number;
durationMs?: number;
finishReason?: string;
isError?: boolean;
usage?: TokenUsage;
/** Context-window fill after this step (the agent-core snapshot formula). */
contextTokens?: number;
llmFirstTokenLatencyMs?: number;
llmStreamDurationMs?: number;
content: ContentSummary;
toolCalls: ToolCallNode[];
}
export interface TurnNode {
index: number;
/** 'prompt' | 'steer' — how the turn was kicked off. */
trigger: 'prompt' | 'steer';
promptLineNo: number;
promptTime?: number;
promptText: string;
originKind?: string;
steps: StepNode[];
startTime?: number;
endTime?: number;
/** endTime startTime over the turn's steps (active execution time). */
durationMs?: number;
/** promptTime previous turn's endTime (time the agent sat idle/waiting). */
waitBeforeMs?: number;
/** Sum of this turn's step usages — total tokens processed (billing cost). */
tokens: TokenUsage;
toolCallCount: number;
toolErrorCount: number;
cancelled: boolean;
}
export interface ContextPoint {
lineNo: number;
time?: number;
turnIndex: number;
step: number;
contextTokens: number;
}
export interface ToolStat {
name: string;
count: number;
errorCount: number;
truncatedCount: number;
/** Number of calls that had both call and result times (so durationMs). */
timedCount: number;
totalMs: number;
avgMs: number | null;
maxMs: number | null;
totalOutputBytes: number;
}
export interface IdleGap {
afterLineNo: number;
beforeLineNo: number;
gapMs: number;
/** Heuristic label for what the gap represents. */
kind: 'between_turns' | 'in_turn';
}
export interface ConfigChange {
lineNo: number;
time?: number;
/** Human-readable field=value pairs that this config.update changed. */
changed: { field: string; value: string }[];
}
export interface CacheStats {
inputOther: number;
inputCacheRead: number;
inputCacheCreation: number;
output: number;
/** cacheRead / (cacheRead + cacheCreation + inputOther). null when no input. */
hitRate: number | null;
}
export interface AnalysisSummary {
turnCount: number;
stepCount: number;
toolCallCount: number;
toolErrorCount: number;
truncatedToolCount: number;
/** Sum of all step usages — total tokens processed across the session. */
totalTokens: number;
/** Latest context-window fill (last step.end snapshot). */
contextTokens: number;
/** Peak context-window fill seen across the session. */
peakContextTokens: number;
/** lastRecordTime firstRecordTime. */
wallClockMs: number | null;
/** Sum of turn active durations (excludes idle/waiting). */
activeMs: number;
}
export interface Analysis {
turns: TurnNode[];
summary: AnalysisSummary;
contextSeries: ContextPoint[];
cache: CacheStats;
toolStats: ToolStat[];
idleGaps: IdleGap[];
configChanges: ConfigChange[];
}
const ZERO_USAGE: TokenUsage = {
inputOther: 0,
output: 0,
inputCacheRead: 0,
inputCacheCreation: 0,
};
/** Idle gaps shorter than this are noise; only larger ones get surfaced. */
const IDLE_GAP_MS = 3000;
function addUsage(into: TokenUsage, u: TokenUsage): void {
into.inputOther += u.inputOther;
into.output += u.output;
into.inputCacheRead += u.inputCacheRead;
into.inputCacheCreation += u.inputCacheCreation;
}
function usageTotal(u: TokenUsage): number {
return u.inputOther + u.output + u.inputCacheRead + u.inputCacheCreation;
}
/** Context-window fill after a step, mirroring agent-core ContextMemory. */
function contextFill(u: TokenUsage): number {
return u.inputCacheRead + u.inputCacheCreation + u.inputOther + u.output;
}
function firstText(input: readonly unknown[] | undefined): string {
if (!input) return '';
for (const part of input) {
if (part && typeof part === 'object' && (part as { type?: string }).type === 'text') {
return (part as { text?: string }).text ?? '';
}
}
return '';
}
function outputSize(output: unknown): number {
if (typeof output === 'string') return output.length;
if (Array.isArray(output)) {
let n = 0;
for (const part of output) {
const text = (part as { text?: string })?.text;
n += typeof text === 'string' ? text.length : JSON.stringify(part ?? null).length;
}
return n;
}
return 0;
}
export function analyzeWire(entries: readonly WireEntry[]): Analysis {
const turns: TurnNode[] = [];
const contextSeries: ContextPoint[] = [];
const toolStatMap = new Map<string, ToolStat>();
const idleGaps: IdleGap[] = [];
const stepByUuid = new Map<string, StepNode>();
const toolByCallId = new Map<string, ToolCallNode>();
const cache: TokenUsage = { ...ZERO_USAGE };
const configChanges: ConfigChange[] = [];
let current: TurnNode | null = null;
let contextTokens = 0;
let peakContext = 0;
let firstTime: number | undefined;
let lastTime: number | undefined;
let prevTime: number | undefined;
let prevLineNo = 0;
const startTurn = (trigger: 'prompt' | 'steer', lineNo: number, time: number | undefined, text: string, originKind: string | undefined): TurnNode => {
const node: TurnNode = {
index: turns.length,
trigger,
promptLineNo: lineNo,
promptTime: time,
promptText: text,
originKind,
steps: [],
tokens: { ...ZERO_USAGE },
toolCallCount: 0,
toolErrorCount: 0,
cancelled: false,
};
if (time !== undefined && current?.endTime !== undefined) {
node.waitBeforeMs = Math.max(0, time - current.endTime);
}
turns.push(node);
return node;
};
for (const entry of entries) {
const rec = entry.data;
const t = rec.time;
if (t !== undefined) {
firstTime ??= t;
lastTime = t;
if (prevTime !== undefined && t - prevTime >= IDLE_GAP_MS) {
idleGaps.push({
afterLineNo: prevLineNo,
beforeLineNo: entry.lineNo,
gapMs: t - prevTime,
// A gap straddling a turn boundary is "waiting for the user"; a gap
// inside a turn is the agent/tool being slow.
kind: rec.type === 'turn.prompt' || rec.type === 'turn.steer' ? 'between_turns' : 'in_turn',
});
}
prevTime = t;
prevLineNo = entry.lineNo;
}
switch (rec.type) {
case 'turn.prompt':
current = startTurn('prompt', entry.lineNo, t, firstText(rec.input), rec.origin?.kind);
break;
case 'turn.steer':
current = startTurn('steer', entry.lineNo, t, firstText(rec.input), rec.origin?.kind);
break;
case 'turn.cancel':
if (current) current.cancelled = true;
break;
case 'context.clear':
contextTokens = 0;
break;
case 'context.apply_compaction':
contextTokens = rec.tokensAfter;
contextSeries.push({ lineNo: entry.lineNo, time: t, turnIndex: current?.index ?? -1, step: -1, contextTokens });
if (contextTokens > peakContext) peakContext = contextTokens;
break;
case 'config.update': {
const changed: { field: string; value: string }[] = [];
if (rec.profileName !== undefined) changed.push({ field: 'profile', value: rec.profileName });
if (rec.modelAlias !== undefined) changed.push({ field: 'model', value: rec.modelAlias });
if (rec.thinkingLevel !== undefined) changed.push({ field: 'thinking', value: rec.thinkingLevel });
if (rec.cwd !== undefined) changed.push({ field: 'cwd', value: rec.cwd });
if (rec.systemPrompt !== undefined) changed.push({ field: 'systemPrompt', value: `${rec.systemPrompt.length} chars` });
if (changed.length > 0) configChanges.push({ lineNo: entry.lineNo, time: t, changed });
break;
}
case 'context.append_loop_event': {
const ev = rec.event;
if (ev.type === 'step.begin') {
current ??= startTurn('prompt', entry.lineNo, t, '(no prompt record)', undefined);
const step: StepNode = {
uuid: ev.uuid,
step: ev.step,
turnId: ev.turnId,
beginLineNo: entry.lineNo,
beginTime: t,
content: { textChars: 0, thinkChars: 0 },
toolCalls: [],
};
stepByUuid.set(ev.uuid, step);
current.steps.push(step);
current.startTime ??= t;
} else if (ev.type === 'step.end') {
const step = stepByUuid.get(ev.uuid);
if (step) {
step.endLineNo = entry.lineNo;
step.endTime = t;
step.finishReason = ev.finishReason;
step.llmFirstTokenLatencyMs = ev.llmFirstTokenLatencyMs;
step.llmStreamDurationMs = ev.llmStreamDurationMs;
if (step.beginTime !== undefined && t !== undefined) step.durationMs = t - step.beginTime;
// Steps don't carry a generic 'error' finish reason (errors are
// thrown, not recorded). 'filtered' means the provider blocked the
// response — the closest persisted step-level failure signal.
step.isError = ev.finishReason === 'filtered';
if ('usage' in ev && ev.usage !== undefined) {
step.usage = ev.usage;
if (current) addUsage(current.tokens, ev.usage);
addUsage(cache, ev.usage);
// A zero-usage step.end (e.g. a content-filtered response) must
// not reset the context-window fill to 0 — agent-core's
// ContextMemory keeps the prior snapshot in that case. Carry the
// running value so the chart shows no false drop.
const fill = contextFill(ev.usage);
if (fill > 0) {
contextTokens = fill;
if (contextTokens > peakContext) peakContext = contextTokens;
}
step.contextTokens = contextTokens;
contextSeries.push({
lineNo: entry.lineNo,
time: t,
turnIndex: current?.index ?? -1,
step: ev.step,
contextTokens,
});
}
if (current && t !== undefined) current.endTime = t;
}
} else if (ev.type === 'tool.call') {
const node: ToolCallNode = {
callLineNo: entry.lineNo,
toolCallId: ev.toolCallId,
name: ev.name,
description: ev.description,
callTime: t,
};
toolByCallId.set(ev.toolCallId, node);
const step = stepByUuid.get(ev.stepUuid);
(step ? step.toolCalls : current?.steps.at(-1)?.toolCalls)?.push(node);
if (current) current.toolCallCount += 1;
} else if (ev.type === 'content.part') {
const step = stepByUuid.get(ev.stepUuid);
const part = ev.part as { type?: string; text?: string } | undefined;
if (step && part) {
const chars = typeof part.text === 'string' ? part.text.length : 0;
if (part.type === 'think') step.content.thinkChars += chars;
else step.content.textChars += chars;
}
} else if (ev.type === 'tool.result') {
const node = toolByCallId.get(ev.toolCallId);
const isError = ev.result.isError === true;
const truncated = ev.result.truncated === true;
const bytes = outputSize(ev.result.output);
if (node) {
node.resultLineNo = entry.lineNo;
node.resultTime = t;
node.isError = isError;
node.truncated = truncated;
node.outputBytes = bytes;
node.resultMessage = ev.result.message;
if (node.callTime !== undefined && t !== undefined) node.durationMs = t - node.callTime;
if (isError && current) current.toolErrorCount += 1;
recordToolStat(toolStatMap, node);
}
}
break;
}
default:
break;
}
}
// Tool calls that never resolved still count toward stats (no duration).
for (const node of toolByCallId.values()) {
if (node.resultLineNo === undefined) recordToolStat(toolStatMap, node);
}
const summary = summarize(turns, contextTokens, peakContext, firstTime, lastTime);
for (const s of toolStatMap.values()) {
s.avgMs = s.timedCount > 0 ? s.totalMs / s.timedCount : null;
}
const toolStats = [...toolStatMap.values()].toSorted((a, b) => b.count - a.count);
const sortedGaps = idleGaps.toSorted((a, b) => b.gapMs - a.gapMs);
return {
turns,
summary,
contextSeries,
cache: cacheStats(cache),
toolStats,
idleGaps: sortedGaps,
configChanges,
};
}
function recordToolStat(map: Map<string, ToolStat>, node: ToolCallNode): void {
let s = map.get(node.name);
if (!s) {
s = { name: node.name, count: 0, errorCount: 0, truncatedCount: 0, timedCount: 0, totalMs: 0, avgMs: null, maxMs: null, totalOutputBytes: 0 };
map.set(node.name, s);
}
s.count += 1;
if (node.isError) s.errorCount += 1;
if (node.truncated) s.truncatedCount += 1;
if (node.outputBytes !== undefined) s.totalOutputBytes += node.outputBytes;
if (node.durationMs !== undefined) {
s.timedCount += 1;
s.totalMs += node.durationMs;
s.maxMs = s.maxMs === null ? node.durationMs : Math.max(s.maxMs, node.durationMs);
}
}
function summarize(
turns: readonly TurnNode[],
contextTokens: number,
peakContext: number,
firstTime: number | undefined,
lastTime: number | undefined,
): AnalysisSummary {
let stepCount = 0;
let toolCallCount = 0;
let toolErrorCount = 0;
let truncatedToolCount = 0;
let totalTokens = 0;
let activeMs = 0;
for (const turn of turns) {
if (turn.startTime !== undefined && turn.endTime !== undefined) {
turn.durationMs = turn.endTime - turn.startTime;
}
stepCount += turn.steps.length;
toolCallCount += turn.toolCallCount;
toolErrorCount += turn.toolErrorCount;
totalTokens += usageTotal(turn.tokens);
activeMs += turn.durationMs ?? 0;
for (const step of turn.steps) {
for (const tc of step.toolCalls) if (tc.truncated) truncatedToolCount += 1;
}
}
return {
turnCount: turns.length,
stepCount,
toolCallCount,
toolErrorCount,
truncatedToolCount,
totalTokens,
contextTokens,
peakContextTokens: peakContext,
wallClockMs: firstTime !== undefined && lastTime !== undefined ? lastTime - firstTime : null,
activeMs,
};
}
function cacheStats(c: TokenUsage): CacheStats {
const inputTotal = c.inputOther + c.inputCacheRead + c.inputCacheCreation;
return {
inputOther: c.inputOther,
inputCacheRead: c.inputCacheRead,
inputCacheCreation: c.inputCacheCreation,
output: c.output,
hitRate: inputTotal > 0 ? c.inputCacheRead / inputTotal : null,
};
}

View file

@ -4,6 +4,10 @@
// Detection rules for the new agent-core wire protocol:
// - tool.call without paired tool.result (orphan tool.call)
// - tool.result without preceding tool.call (orphan tool.result)
// - tool.result with isError (tool failed)
// - tool.result with truncated output (model saw partial output)
// - step.end finishReason 'filtered' (provider blocked the response)
// - step.end finishReason 'max_tokens' (response cut at the output cap)
// - step.begin without paired step.end (incomplete step)
// - full_compaction.begin without complete/cancel (incomplete compaction)
// - plan_mode.enter without exit/cancel (still in plan mode)
@ -18,6 +22,10 @@ export type IssueSeverity = 'error' | 'warning' | 'info';
export type IssueKind =
| 'orphan_tool_call'
| 'missing_tool_result'
| 'tool_error'
| 'tool_truncated'
| 'model_filtered'
| 'model_max_tokens'
| 'incomplete_step'
| 'incomplete_compaction'
| 'active_plan_mode'
@ -78,6 +86,25 @@ export function computeIssues(
detail: 'no preceding tool.call seen',
});
}
// Runtime failure / partial-output signals carried on the result.
if (ev.result.isError === true) {
out.push({
severity: 'error',
kind: 'tool_error',
lineNo,
summary: `${open?.name ?? 'tool'}#${ev.toolCallId.slice(-8)} returned an error`,
detail: ev.result.message,
});
}
if (ev.result.truncated === true) {
out.push({
severity: 'info',
kind: 'tool_truncated',
lineNo,
summary: `${open?.name ?? 'tool'}#${ev.toolCallId.slice(-8)} output truncated`,
detail: 'the model saw a paged/dropped partial result',
});
}
} else if (ev.type === 'step.begin') {
stepBeginByUuid.set(ev.uuid, {
lineNo,
@ -86,6 +113,23 @@ export function computeIssues(
});
} else if (ev.type === 'step.end') {
stepBeginByUuid.delete(ev.uuid);
if (ev.finishReason === 'filtered') {
out.push({
severity: 'error',
kind: 'model_filtered',
lineNo,
summary: `step ${ev.step} response filtered by the provider`,
detail: ev.rawFinishReason ?? ev.providerFinishReason,
});
} else if (ev.finishReason === 'max_tokens') {
out.push({
severity: 'warning',
kind: 'model_max_tokens',
lineNo,
summary: `step ${ev.step} hit the output token cap`,
detail: 'the response was cut short at max_tokens',
});
}
}
break;
}

View file

@ -4,19 +4,27 @@ import { useParams } from 'react-router-dom';
import { api } from '../api';
import { CopyButton } from '../components/shared/CopyButton';
import { TabBar, useActiveTab } from '../components/layout/TabBar';
import { TimelineTab } from '../components/analysis/TimelineTab';
import { ContextTab } from '../components/context/ContextTab';
import { CronTab } from '../components/tasks/CronTab';
import { LogsTab } from '../components/logs/LogsTab';
import { StateTab } from '../components/state/StateTab';
import { SubagentsTab } from '../components/subagents/SubagentsTab';
import { TasksTab } from '../components/tasks/TasksTab';
import { WireTab } from '../components/wire/WireTab';
import { Pill } from '../components/shared/Pill';
import { useSession } from '../hooks/useSession';
import { useCron, useTasks } from '../hooks/useTasks';
import { formatAbsoluteTime, formatRelativeTime } from '../util/time';
type TabId = 'wire' | 'context' | 'agents' | 'state';
type TabId = 'wire' | 'timeline' | 'context' | 'agents' | 'tasks' | 'cron' | 'logs' | 'state';
export function SessionDetailPage() {
const { sessionId } = useParams<{ sessionId: string }>();
const active = useActiveTab('wire') as TabId;
const { data: session, isLoading, error } = useSession(sessionId);
const { data: tasksData } = useTasks(sessionId);
const { data: cronData } = useCron(sessionId);
if (!sessionId) return <div className="p-6 text-fg-3">(no session id)</div>;
if (isLoading) {
@ -48,6 +56,9 @@ export function SessionDetailPage() {
<div className="flex items-center gap-3">
<span className="font-mono text-[14px] text-fg-0">{session.sessionId}</span>
<CopyButton value={session.sessionId} />
{session.imported ? (
<Pill tone="subagent" variant="outline">imported</Pill>
) : null}
{state?.title ? (
<span className="font-mono text-[12px] text-fg-1">"{state.title}"</span>
) : null}
@ -56,6 +67,18 @@ export function SessionDetailPage() {
<CopyButton value={session.sessionDir} label="copy path" />
</span>
</div>
{session.imported && session.importMeta ? (
<div className="mt-1 flex flex-wrap items-center gap-3 font-mono text-[10.5px] text-fg-3">
{session.importMeta.manifest?.kimiCodeVersion ? (
<span>kimi-code v{session.importMeta.manifest.kimiCodeVersion}</span>
) : null}
{session.importMeta.manifest?.os ? <span>· {session.importMeta.manifest.os}</span> : null}
{session.importMeta.manifest?.exportedAt ? (
<span>· exported {formatRelativeTime(Date.parse(session.importMeta.manifest.exportedAt))}</span>
) : null}
{session.importMeta.originalName ? <span>· {session.importMeta.originalName}</span> : null}
</div>
) : null}
<div className="mt-1 flex items-center gap-3 font-mono text-[11px] text-fg-2">
{state?.updatedAt ? (
<span className="text-fg-3 tabular">
@ -86,17 +109,25 @@ export function SessionDetailPage() {
defaultTab="wire"
tabs={[
{ id: 'wire', label: 'Wire', count: wireRecords },
{ id: 'timeline', label: 'Timeline', count: null },
{ id: 'context', label: 'Context', count: null },
{ id: 'agents', label: 'Agents', count: subagentCount },
{ id: 'tasks', label: 'Tasks', count: tasksData?.tasks.length ?? null },
{ id: 'cron', label: 'Cron', count: cronData?.cron.length ?? null },
{ id: 'logs', label: 'Logs', count: null },
{ id: 'state', label: 'State', count: null },
]}
/>
<div className="flex min-h-0 flex-1 flex-col">
{active === 'wire' ? <WireTab sessionId={sessionId} /> : null}
{active === 'timeline' ? <TimelineTab sessionId={sessionId} /> : null}
{active === 'context' ? <ContextTab sessionId={sessionId} /> : null}
{active === 'agents' ? <SubagentsTab sessionId={sessionId} /> : null}
{active === 'state' ? <StateTab state={session.state} /> : null}
{active === 'tasks' ? <TasksTab sessionId={sessionId} /> : null}
{active === 'cron' ? <CronTab sessionId={sessionId} /> : null}
{active === 'logs' ? <LogsTab sessionId={sessionId} /> : null}
{active === 'state' ? <StateTab state={session.state} importMeta={session.importMeta} /> : null}
</div>
</div>
);

View file

@ -22,6 +22,21 @@ export type {
ContentPart,
Message,
ToolCall,
BackgroundTaskInfo,
BackgroundTaskStatus,
ProcessBackgroundTaskInfo,
AgentBackgroundTaskInfo,
QuestionBackgroundTaskInfo,
BackgroundTaskEntry,
BackgroundTasksResponse,
TaskOutputResponse,
CronTask,
CronTasksResponse,
ImportInfo,
ImportManifest,
ImportResult,
LogLine,
LogsResponse,
} from '../../server/src/lib/agent-record-types';
export type {

View file

@ -31,3 +31,21 @@ export function formatWallClock(epochMs: number): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** Format a duration in ms as a compact human string (e.g. "840ms", "2.4s", "1m03s"). */
export function formatDuration(ms: number | null | undefined): string {
if (ms === null || ms === undefined || !Number.isFinite(ms)) return '—';
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}m${String(s).padStart(2, '0')}s`;
}
/** Format a token count compactly (e.g. "512", "12.4k", "1.20M"). */
export function formatTokens(n: number | null | undefined): string {
if (n === null || n === undefined || !Number.isFinite(n)) return '—';
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
return `${(n / 1_000_000).toFixed(2)}M`;
}

View file

@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { analyzeWire } from '../src/lib/analysis';
import type { WireEntry } from '../src/types';
let line = 0;
function e(data: Record<string, unknown>, time?: number): WireEntry {
line += 1;
return { lineNo: line, data: { ...data, time }, raw: data } as unknown as WireEntry;
}
function loop(event: Record<string, unknown>, time?: number): WireEntry {
return e({ type: 'context.append_loop_event', event }, time);
}
describe('analyzeWire', () => {
it('folds a session into turns/steps/tools with derived metrics', () => {
line = 0;
const entries: WireEntry[] = [
e({ type: 'turn.prompt', input: [{ type: 'text', text: 'hello' }], origin: { kind: 'user' } }, 1000),
loop({ type: 'step.begin', uuid: 's1', turnId: 'T1', step: 0 }, 1100),
loop({ type: 'tool.call', uuid: 'tc1', turnId: 'T1', step: 0, stepUuid: 's1', toolCallId: 'c1', name: 'Read' }, 1200),
loop({ type: 'tool.result', parentUuid: 'tc1', toolCallId: 'c1', result: { output: 'x'.repeat(50), truncated: true } }, 1500),
loop({ type: 'step.end', uuid: 's1', turnId: 'T1', step: 0, finishReason: 'tool_use', llmFirstTokenLatencyMs: 40, usage: { inputOther: 100, output: 20, inputCacheRead: 80, inputCacheCreation: 10 } }, 1600),
loop({ type: 'step.begin', uuid: 's2', turnId: 'T1', step: 1 }, 1700),
loop({ type: 'step.end', uuid: 's2', turnId: 'T1', step: 1, finishReason: 'end_turn', usage: { inputOther: 200, output: 50, inputCacheRead: 150, inputCacheCreation: 0 } }, 2000),
// Big idle gap → waiting for the user, then a second turn that errors.
e({ type: 'turn.prompt', input: [{ type: 'text', text: 'again' }], origin: { kind: 'user' } }, 10000),
loop({ type: 'step.begin', uuid: 's3', turnId: 'T2', step: 0 }, 10100),
loop({ type: 'tool.call', uuid: 'tc2', turnId: 'T2', step: 0, stepUuid: 's3', toolCallId: 'c2', name: 'Read' }, 10200),
loop({ type: 'tool.result', parentUuid: 'tc2', toolCallId: 'c2', result: { output: 'y'.repeat(10), isError: true } }, 10250),
loop({ type: 'step.end', uuid: 's3', turnId: 'T2', step: 0, finishReason: 'filtered', usage: { inputOther: 300, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, 10300),
];
const a = analyzeWire(entries);
// Turn grouping
expect(a.turns).toHaveLength(2);
expect(a.turns[0]!.promptText).toBe('hello');
expect(a.turns[0]!.trigger).toBe('prompt');
expect(a.turns[0]!.steps).toHaveLength(2);
expect(a.turns[1]!.steps).toHaveLength(1);
// Tool duration + truncation + size
const tc = a.turns[0]!.steps[0]!.toolCalls[0]!;
expect(tc.durationMs).toBe(300);
expect(tc.truncated).toBe(true);
expect(tc.outputBytes).toBe(50);
expect(tc.isError).toBe(false);
// Context-window fill snapshots (agent-core formula)
expect(a.turns[0]!.steps[0]!.contextTokens).toBe(210); // 100+20+80+10
expect(a.turns[0]!.steps[1]!.contextTokens).toBe(400); // 200+50+150+0
expect(a.summary.peakContextTokens).toBe(400);
expect(a.contextSeries.map((p) => p.contextTokens)).toEqual([210, 400, 300]);
// Per-turn token cost = sum of step usages
expect(a.turns[0]!.tokens).toEqual({ inputOther: 300, output: 70, inputCacheRead: 230, inputCacheCreation: 10 });
// Idle / wait
expect(a.turns[1]!.waitBeforeMs).toBe(8000);
expect(a.idleGaps).toHaveLength(1);
expect(a.idleGaps[0]).toMatchObject({ gapMs: 8000, kind: 'between_turns', afterLineNo: 7, beforeLineNo: 8 });
// Errors
expect(a.turns[1]!.steps[0]!.isError).toBe(true); // finishReason 'filtered'
expect(a.turns[1]!.toolErrorCount).toBe(1);
// Summary
expect(a.summary.turnCount).toBe(2);
expect(a.summary.stepCount).toBe(3);
expect(a.summary.toolCallCount).toBe(2);
expect(a.summary.toolErrorCount).toBe(1);
expect(a.summary.truncatedToolCount).toBe(1);
// Tool stats
const read = a.toolStats.find((s) => s.name === 'Read')!;
expect(read.count).toBe(2);
expect(read.errorCount).toBe(1);
expect(read.truncatedCount).toBe(1);
expect(read.timedCount).toBe(2);
expect(read.totalMs).toBe(350); // 300 + 50
expect(read.avgMs).toBe(175);
expect(read.maxMs).toBe(300);
});
it('handles an empty wire', () => {
const a = analyzeWire([]);
expect(a.turns).toEqual([]);
expect(a.summary.turnCount).toBe(0);
expect(a.cache.hitRate).toBeNull();
});
it('computes cache hit rate from summed input usage', () => {
line = 0;
const a = analyzeWire([
e({ type: 'turn.prompt', input: [{ type: 'text', text: 'q' }], origin: { kind: 'user' } }, 0),
loop({ type: 'step.begin', uuid: 'x', turnId: 'A', step: 0 }, 1),
loop({ type: 'step.end', uuid: 'x', turnId: 'A', step: 0, finishReason: 'end_turn', usage: { inputOther: 25, output: 5, inputCacheRead: 75, inputCacheCreation: 0 } }, 2),
]);
// hitRate = 75 / (75 + 0 + 25) = 0.75
expect(a.cache.hitRate).toBeCloseTo(0.75, 5);
});
it('collects config.update changes', () => {
line = 0;
const a = analyzeWire([
e({ type: 'config.update', modelAlias: 'opus', thinkingLevel: 'high', systemPrompt: 'x'.repeat(120) }, 0),
e({ type: 'config.update', modelAlias: 'sonnet' }, 10),
]);
expect(a.configChanges).toHaveLength(2);
expect(a.configChanges[0]!.changed).toEqual([
{ field: 'model', value: 'opus' },
{ field: 'thinking', value: 'high' },
{ field: 'systemPrompt', value: '120 chars' },
]);
expect(a.configChanges[1]!.changed).toEqual([{ field: 'model', value: 'sonnet' }]);
});
it('does not reset context-window fill on a zero-usage step.end', () => {
line = 0;
const a = analyzeWire([
e({ type: 'turn.prompt', input: [{ type: 'text', text: 'q' }], origin: { kind: 'user' } }, 0),
loop({ type: 'step.begin', uuid: 's1', turnId: 'T', step: 0 }, 1),
loop({ type: 'step.end', uuid: 's1', turnId: 'T', step: 0, finishReason: 'tool_use', usage: { inputOther: 100, output: 20, inputCacheRead: 80, inputCacheCreation: 0 } }, 2),
loop({ type: 'step.begin', uuid: 's2', turnId: 'T', step: 1 }, 3),
// content-filtered: usage all zero — must keep the prior 200, not drop to 0.
loop({ type: 'step.end', uuid: 's2', turnId: 'T', step: 1, finishReason: 'filtered', usage: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, 4),
]);
expect(a.turns[0]!.steps[0]!.contextTokens).toBe(200);
expect(a.turns[0]!.steps[1]!.contextTokens).toBe(200); // carried, not 0
expect(a.contextSeries.map((p) => p.contextTokens)).toEqual([200, 200]);
expect(a.summary.peakContextTokens).toBe(200);
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { computeIssues } from '../src/lib/issues';
import type { WireEntry } from '../src/types';
let line = 0;
function loop(event: Record<string, unknown>): WireEntry {
line += 1;
return { lineNo: line, data: { type: 'context.append_loop_event', event }, raw: {} } as unknown as WireEntry;
}
describe('computeIssues — runtime error categories', () => {
it('flags tool errors, truncation, filtered + max_tokens steps', () => {
line = 0;
const entries: WireEntry[] = [
loop({ type: 'step.begin', uuid: 's1', turnId: 'T', step: 0 }),
loop({ type: 'tool.call', uuid: 'a', turnId: 'T', step: 0, stepUuid: 's1', toolCallId: 'c1', name: 'Bash' }),
loop({ type: 'tool.result', parentUuid: 'a', toolCallId: 'c1', result: { output: 'boom', isError: true, message: 'exit 1' } }),
loop({ type: 'tool.call', uuid: 'b', turnId: 'T', step: 0, stepUuid: 's1', toolCallId: 'c2', name: 'Read' }),
loop({ type: 'tool.result', parentUuid: 'b', toolCallId: 'c2', result: { output: 'partial', truncated: true } }),
loop({ type: 'step.end', uuid: 's1', turnId: 'T', step: 0, finishReason: 'filtered', rawFinishReason: 'content_filter' }),
loop({ type: 'step.begin', uuid: 's2', turnId: 'T', step: 1 }),
loop({ type: 'step.end', uuid: 's2', turnId: 'T', step: 1, finishReason: 'max_tokens' }),
];
const issues = computeIssues(entries, []);
const byKind = new Map(issues.map((i) => [i.kind, i]));
expect(byKind.get('tool_error')).toMatchObject({ severity: 'error', detail: 'exit 1' });
expect(byKind.get('tool_truncated')).toMatchObject({ severity: 'info' });
expect(byKind.get('model_filtered')).toMatchObject({ severity: 'error', detail: 'content_filter' });
expect(byKind.get('model_max_tokens')).toMatchObject({ severity: 'warning' });
// The tool.result rows are properly paired, so no orphan noise.
expect(issues.some((i) => i.kind === 'orphan_tool_call' || i.kind === 'missing_tool_result')).toBe(false);
});
});

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
name: 'vis-web',
include: ['test/**/*.test.ts', 'src/**/*.test.ts'],
environment: 'node',
},
});

15
pnpm-lock.yaml generated
View file

@ -227,13 +227,25 @@ importers:
hono:
specifier: ^4.7.7
version: 4.12.14
yauzl:
specifier: ^3.3.0
version: 3.3.0
devDependencies:
'@types/yauzl':
specifier: ^2.10.3
version: 2.10.3
'@types/yazl':
specifier: ^2.4.6
version: 2.4.6
tsx:
specifier: ^4.21.0
version: 4.21.0
vitest:
specifier: 4.1.4
version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
yazl:
specifier: ^3.3.1
version: 3.3.1
apps/vis/web:
dependencies:
@ -277,6 +289,9 @@ importers:
vite-plugin-singlefile:
specifier: ^2.3.3
version: 2.3.3(rollup@4.60.2)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: 4.1.4
version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
docs:
dependencies: