Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions BUILDLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,3 +574,48 @@ keeps its width and the branch gives way first if anything has to. Checked
against the real shared fleet at the browser's own width: nothing clipped, and
the table still fits with no horizontal scroll, which was the constraint that
made this column narrow in the first place.

## 2026-09-07 - Worst-first pushed the table off its own edge

Sorting by problem score put the heaviest sessions on screen at once, and the
Updated column went off the right edge of the card. The widths had only ever
been checked against the recency view, where every row is a one-turn cron job.

Two fixes, both of which the fleet needed anyway. Durations over an hour now
read `66h 26m` rather than `3985m 54s`, which is narrower and, more to the
point, legible - nobody can read 3985 minutes. And the title column, sized at
240px back when titles were prompt text, is 170px now that a shared fleet's
titles are session ids of about eighteen monospace characters.

Measured on the real shared fleet in worst-first: the table is 1042px in a
1042px card, Updated fully visible, and two titles out of two hundred clip -
both local sessions with real prose, which keep their tooltip.

## 2026-09-07 - An outside review, and where it was right

An external review of the tree. Its headline finding was correct and is the
kind of gap worth having someone else find: masking covered `events.raw` and
nothing else. A session title is the first eighty characters of the first
prompt, and a turn error is model or tool text, and both were written to their
own columns in clear. The sharpest illustration was OpenCode, where the same
error string went through `putEvent` masked and into `turns.error` in the
clear, in the same function.

Masking now covers both columns, through `maskText` at every writer: session
titles from six adapters, turn errors from `closeTurn`, `failTurn` and
OpenCode's own state update. `agent_name` is left alone deliberately - it is a
directory basename, and masking it would only mangle repository names.

Also fixed from the same review: the problem score divided two integers, so
fourteen tool calls over three turns scored as four rather than 4.67; the fleet
table's "show more" kept offering more past the server's 2000-row cap, which
3,179 sessions on the shared server made reachable; a port already in use
crashed with a stack trace instead of naming the port; and the README still
claimed the shared server was "not built yet" several sections after describing
how to run one.

Two findings did not survive checking. The claim that the `seq` backfill scans
the whole table on startup is wrong - `EXPLAIN QUERY PLAN` shows
`SEARCH events USING INDEX idx_events_seq`, because the index is created before
the backfill runs. And the uncommitted layout change it flagged was committed
before the review was read.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ Antigravity CLI records no token usage, and only conversations with a row in
them, group under the source name. The Cursor IDE's own chats (not the CLI)
are not read.
Other variables: `AGENTLENS_DB` (`agentlens.db`), `PORT` (`8788`), `MCP_PORT`
(`8791`), `AGENTLENS_HOST` (`127.0.0.1`, see Shared server below).
(`8791`), `AGENTLENS_HOST` (`127.0.0.1`, see Shared server below),
`AGENTLENS_CORS_ORIGIN` (`http://localhost:5173`, only needed if you serve the
dashboard from somewhere other than the Vite dev server).

A session records the git branch it started on, read from `.git/HEAD` in the
harness's working directory - no subprocess, and it works when git is absent.
Expand Down Expand Up @@ -291,8 +293,8 @@ infra traces. TrueForge emits no telemetry itself, so the exporter belongs here.
The store and UI are harness-neutral. Adapters exist for TrueForge, Claude Code,
OpenCode, dsh, Codex CLI, Gemini CLI, Roo Code, and Cline; each new harness is
one file under `packages/server/src/sources/`, or a client of the ingest API.
A shared company server (per-machine shippers pushing to one AgentLens) is the
next step and is not built yet.
A shared company server, with per-machine shippers pushing metadata to one
AgentLens, is built: see "Shared server" above.

## Optional: investigator agent

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"tsx": "^4.20.0",
"typescript": "^5.9.0"
},
"version": "0.12.2",
"version": "0.12.4",
"description": "Local observability for coding-agent harnesses: fleet view and trace viewer for Claude Code, OpenCode, dsh, Cursor Agent, Antigravity, TrueForge and more.",
"license": "MIT",
"repository": {
Expand Down
16 changes: 12 additions & 4 deletions packages/server/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Database from "better-sqlite3";
import { branchOf } from "./git-branch.js";
import { redactEventRaw } from "./redact-secrets.js";
import { maskText, redactEventRaw } from "./redact-secrets.js";

export const db = new Database(process.env.AGENTLENS_DB ?? "agentlens.db");
db.pragma("journal_mode = WAL");
Expand Down Expand Up @@ -102,7 +102,9 @@ export const upsertSession = {
// A shipped session carries the sender's branch; there is no working
// directory to read on the receiving machine.
branch?: string | null;
}) => upsertSessionStmt.run({ ...s, cwd: undefined, branch: s.branch ?? branchOf(s.cwd) }),
// A title is the first 80 characters of the first prompt, so it can carry a
// secret exactly as a prompt can.
}) => upsertSessionStmt.run({ ...s, cwd: undefined, title: maskText(s.title), branch: s.branch ?? branchOf(s.cwd) }),
};

export const sessionSource = db.prepare(`SELECT source FROM sessions WHERE id = ?`);
Expand Down Expand Up @@ -145,12 +147,18 @@ export function sweepStaleTurns() {
return sweepStmt.run().changes;
}

export const upsertTurn = db.prepare(`
const upsertTurnStmt = db.prepare(`
INSERT INTO turns (id, session_id, created_at, completed_at, status, error, ingested, pending_actions)
VALUES (@id, @session_id, @created_at, @completed_at, @status, @error, @ingested, @pending_actions)
ON CONFLICT(id) DO UPDATE SET completed_at=@completed_at, status=@status, error=@error, ingested=@ingested, pending_actions=@pending_actions
`);

// A turn's error is model or tool text and can quote a key just as an event can.
export const upsertTurn = {
run: (t: Record<string, unknown> & { error: string | null }) =>
upsertTurnStmt.run({ ...t, error: maskText(t.error) }),
};

const insertEventStmt = db.prepare(`
INSERT OR IGNORE INTO events (id, session_id, turn_id, thread_id, type, created_at, raw, seq)
VALUES (@id, @session_id, @turn_id, @thread_id, @type, @created_at, @raw, ${NEXT_SEQ})
Expand Down Expand Up @@ -214,7 +222,7 @@ const PROBLEM_SCORE = `
+ MIN(COALESCE((SELECT MAX(gap) FROM (
SELECT strftime('%s', e.created_at) - LAG(strftime('%s', e.created_at)) OVER (ORDER BY e.created_at) gap
FROM events e WHERE e.session_id = s.id AND e.created_at IS NOT NULL)), 0) / 60, 30)
+ MIN(MAX((SELECT COUNT(*) FROM events e WHERE e.session_id = s.id AND e.type = 'tool.response')
+ MIN(MAX((SELECT COUNT(*) FROM events e WHERE e.session_id = s.id AND e.type = 'tool.response') * 1.0
/ MAX((SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id), 1) - 10, 0), 30)`;

export type SessionQuery = {
Expand Down
10 changes: 9 additions & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,17 @@ function serveMain() {
const host = process.env.AGENTLENS_HOST ?? "127.0.0.1";
if (host !== "127.0.0.1" && host !== "localhost")
console.log(`warning: listening on ${host} with no authentication`);
serve({ fetch: app.fetch, port, hostname: host }, () =>
const server = serve({ fetch: app.fetch, port, hostname: host }, () =>
console.log(`agentlens on http://${host === "0.0.0.0" ? "localhost" : host}:${port}`),
);
server.on("error", (err: NodeJS.ErrnoException) => {
console.error(
err.code === "EADDRINUSE"
? `port ${port} is already in use; set PORT to something else`
: `server: ${err.message}`,
);
process.exit(1);
});
}

// `agentlens ship ...` is a client of another AgentLens, not a server.
Expand Down
9 changes: 9 additions & 0 deletions packages/server/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ export function startMcpServer(port = 8791) {
});
// Loopback only: the MCP tools have no auth, so binding to all interfaces
// would expose session traces and the report-write tool to the LAN.
server.on("error", (err: NodeJS.ErrnoException) => {
// Usually a second AgentLens on the same machine. Say which port, and stop.
console.error(
err.code === "EADDRINUSE"
? `mcp port ${port} is already in use; set MCP_PORT to something else`
: `mcp: ${err.message}`,
);
process.exit(1);
});
server.listen(port, "127.0.0.1", () =>
console.log(`agentlens mcp on http://localhost:${port}/mcp`),
);
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/redact-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ export function redactSecrets(text: string): string {
// with no quotes or backslashes, so masking the serialized form cannot break
// the JSON. Doing it here rather than per field covers prompts, model output,
// tool arguments and tool results in one pass.
// For the text columns outside events.raw: session titles and turn errors.
export function maskText<T extends string | null | undefined>(text: T): T {
return (text && redactionEnabled() ? (redactSecrets(text) as T) : text);
}

export function redactEventRaw(raw: string): string {
return redactionEnabled() ? redactSecrets(raw) : raw;
}
5 changes: 3 additions & 2 deletions packages/server/src/sources/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import { db, getCursor, insertEvent, setCursor, turnAt, upsertSession, upsertTurn } from "../db.js";
import { textOf, usageOf } from "./emit.js";
import type { Source } from "./types.js";
import { maskText } from "../redact-secrets.js";

// Claude Code writes ~/.claude/projects/<encoded-cwd>/<session>.jsonl, one JSON
// record per line, append-only. Subagent transcripts live next to it under
Expand Down Expand Up @@ -120,7 +121,7 @@ export function ingestRecords(ctx: Ctx, records: any[], state: FileState) {

if (r.type === "ai-title" || r.type === "custom-title") {
state.title = r.aiTitle ?? r.customTitle ?? state.title;
if (state.title && sessionExists.get(ctx.sessionId)) setTitle.run(state.title, ctx.sessionId);
if (state.title && sessionExists.get(ctx.sessionId)) setTitle.run(maskText(state.title), ctx.sessionId);
continue;
}

Expand Down Expand Up @@ -202,7 +203,7 @@ export function ingestRecords(ctx: Ctx, records: any[], state: FileState) {
});
state.usage_due = false;
}
if (r.isApiErrorMessage && !ctx.threadId) failTurn.run(content.slice(0, 500), turnId);
if (r.isApiErrorMessage && !ctx.threadId) failTurn.run(maskText(content.slice(0, 500)), turnId);
event(ctx, turnId, eid(ctx, r.uuid), "model.message", at, raw);
continue;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/server/src/sources/emit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { userInfo } from "node:os";
import { db, upsertEvent, upsertSession } from "../db.js";
import { maskText } from "../redact-secrets.js";

// Small write helpers shared by adapters that produce the normalized
// vocabulary directly (dsh, codex, gemini, roo, the ingest API).
Expand Down Expand Up @@ -33,7 +34,7 @@ export function ensureSession(s: {
branch?: string | null;
}) {
if (sessionExists.get(s.id)) {
if (s.title) setTitle.run(s.title, s.id);
if (s.title) setTitle.run(maskText(s.title), s.id);
// A branch handed over by a shipper belongs to that session and fills a
// gap on a row that arrived before branches were sent. A branch derived
// from a local working directory is not backfilled here: on an old session
Expand All @@ -57,7 +58,7 @@ export function ensureSession(s: {
export const touch = (sessionId: string, at: string) => touchStmt.run(at, sessionId, at);
export const openTurn = (id: string, sessionId: string, at: string) => openTurnStmt.run(id, sessionId, at);
export const closeTurn = (id: string, status: string, at: string | null, error: string | null = null) =>
closeTurnStmt.run(status, at, error, id);
closeTurnStmt.run(status, at, maskText(error), id);
// Harnesses without a terminal marker: a new prompt ends whatever was running.
export const closeOpenTurns = (sessionId: string, before: string) => closeOpenTurnsStmt.run(sessionId, before);

Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/sources/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { basename } from "node:path";
import { db, getCursor, insertEvent, setCursor, turnAt, upsertEvent, upsertSession } from "../db.js";
import { usageOf } from "./emit.js";
import type { Source } from "./types.js";
import { maskText } from "../redact-secrets.js";

// OpenCode keeps its state in SQLite (session / message / part tables, JSON in
// `data`). Rows are updated in place while a step runs, so events are upserted
Expand Down Expand Up @@ -161,7 +162,7 @@ export function createOpenCode(src: Database): Source {
}
if (status === "running") return;
const completed = iso(Math.max(...replies.map((r) => r.time?.completed ?? r.time?.created ?? 0)));
setTurnState.run(status, completed, message, `oc:${userMsgId}`);
setTurnState.run(status, completed, maskText(message), `oc:${userMsgId}`);
const cost = replies.reduce((n, r) => n + (r.cost ?? 0), 0);
insertEvent.run({
id: `oc:${userMsgId}:done`,
Expand Down
35 changes: 35 additions & 0 deletions packages/server/test/redact-secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { test } from "node:test";
import { db, insertEvent, seedSession, sessionTrace, upsertEvent } from "./fixtures.ts";

const { redactSecrets } = await import("../src/redact-secrets.ts");
const { upsertSession, upsertTurn } = await import("../src/db.ts");

// Fake credentials are assembled at runtime rather than written out. They are
// invented, but they match real formats by design, and a literal in the source
Expand Down Expand Up @@ -100,3 +101,37 @@ test("a private key block is masked whole", () => {
const out = redactSecrets(`here it is:\n${pem}\nthat was it`);
assert.equal(out, "here it is:\n[REDACTED:private-key]\nthat was it");
});

test("a secret in a session title is masked", () => {
// A first prompt that opens with a key becomes the session title.
upsertSession.run({
id: "r-title2",
agent_name: "repo",
title: `use ${fake.githubPat} to fetch it`,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
created_by: "test",
source: "claude-code",
});
const t = db.prepare(`SELECT title FROM sessions WHERE id = ?`).get("r-title2") as { title: string };
assert.ok(!t.title.includes(fake.githubPat), "the token is not stored in the title");
assert.ok(t.title.includes("[REDACTED:github-pat]") && t.title.includes("to fetch it"));
});

test("a turn error carrying a key is masked", () => {
seedSession("r-err");
upsertTurn.run({
id: "r-err-t1",
session_id: "r-err",
created_at: "2026-09-01T00:00:00Z",
completed_at: "2026-09-01T00:01:00Z",
status: "error",
error: `auth failed for ${fake.openaiProject}`,
ingested: 1,
pending_actions: 0,
});
const row = db.prepare(`SELECT error FROM turns WHERE id = ?`).get("r-err-t1") as { error: string };
assert.ok(!row.error.includes(fake.openaiProject));
assert.ok(row.error.includes("[REDACTED:openai-project-key]"));
assert.ok(row.error.startsWith("auth failed for"), "the rest of the message survives");
});
32 changes: 24 additions & 8 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from "react";
import { api, type FleetTotals, type Report, type SessionSummary, type SourceStatus, type Trace, type TraceEvent } from "./api";

const PAGE = 200;
// The server clamps a page to this, so asking for more silently does nothing.
const MAX_ROWS = 2000;

const fmtTokens = (n: number | null | undefined) =>
n == null
Expand All @@ -17,8 +19,14 @@ const fmtTokens = (n: number | null | undefined) =>
// more honestly than $0.00 does.
const fmtCost = (n: number | null | undefined) =>
!n || n < 0.005 ? null : n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`;
const fmtDur = (s: number | null | undefined) =>
s == null ? "-" : s >= 60 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${Math.round(s)}s`;
// A session that ran for 3985m says nothing; 66h 26m does, and it is narrower,
// which matters because worst-first puts the longest sessions on screen at once.
const fmtDur = (s: number | null | undefined) => {
if (s == null) return "-";
if (s < 60) return `${Math.round(s)}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
return `${Math.floor(s / 3600)}h ${Math.round((s % 3600) / 60)}m`;
};
const fmtAge = (iso: string | null | undefined) => {
if (!iso) return null;
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
Expand Down Expand Up @@ -193,9 +201,10 @@ export function App() {
q={q}
setQ={setQ}
matched={matched}
atCap={limit >= MAX_ROWS}
sort={sort}
setSort={setSort}
onMore={() => setLimit((n) => n + PAGE)}
onMore={() => setLimit((n) => Math.min(n + PAGE, MAX_ROWS))}
/>
<ReportPanel reports={reports} />
</main>
Expand Down Expand Up @@ -244,6 +253,7 @@ function SessionTable({
q,
setQ,
matched,
atCap,
sort,
setSort,
onMore,
Expand All @@ -253,6 +263,7 @@ function SessionTable({
q: string;
setQ: (q: string) => void;
matched: number;
atCap: boolean;
sort: "recent" | "score";
setSort: (s: "recent" | "score") => void;
onMore: () => void;
Expand Down Expand Up @@ -372,11 +383,16 @@ function SessionTable({
)}
</tbody>
</table>
{matched > rows.length && (
<button className="more" onClick={onMore}>
show {Math.min(PAGE, matched - rows.length)} more
</button>
)}
{matched > rows.length &&
(atCap ? (
<div className="more muted">
showing the first {rows.length} of {matched}; narrow the filter to see the rest
</div>
) : (
<button className="more" onClick={onMore}>
show {Math.min(PAGE, matched - rows.length)} more
</button>
))}
</section>
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ td { padding: 9px 8px; border-bottom: 1px solid var(--border); white-space: nowr
.est { color: var(--dim); }
.branch { color: var(--dim); }
td .title.dim { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px; }
td .title { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td .agent { display: flex; gap: 6px; max-width: 300px; }
td .title { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td .agent { display: flex; gap: 6px; max-width: 240px; }
td .agent .repo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td .agent .branch { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 0 1 auto; min-width: 0; }
tbody tr.clickable { cursor: pointer; }
Expand Down