feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
@@ -0,0 +1,669 @@
/**
* @file Unit tests for the TranscriptCache class, which extracts token usage from Claude transcript JSONL files and caches results for performance. Tests cover cache hits/misses, compaction detection, multiple models, and edge cases like malformed files and eviction behavior.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
let tmpDir;
let TranscriptCache;
function writeJsonl(filePath, entries) {
fs.writeFileSync(filePath, entries.map((e) => JSON.stringify(e)).join("\n") + "\n");
}
describe("TranscriptCache", () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-test-"));
delete require.cache[require.resolve("../../lib/transcript-cache")];
TranscriptCache = require("../../lib/transcript-cache");
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("should extract tokens on first read (cache miss)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 200, output_tokens: 75 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.deepStrictEqual(result.tokensByModel, {
"claude-sonnet-4-20250514": { input: 300, output: 125, cacheRead: 0, cacheWrite: 0 },
});
assert.strictEqual(result.compaction, null);
});
it("should return cached result when file is unchanged", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
const r2 = cache.extract(file);
assert.deepStrictEqual(r1, r2);
// Same object reference proves cache hit (no re-parse)
assert.strictEqual(r1, r2);
});
it("should detect new data when file grows", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
assert.strictEqual(r1.tokensByModel["claude-sonnet-4-20250514"].input, 100);
// Append more data (simulates Claude writing to transcript)
fs.appendFileSync(
file,
JSON.stringify({
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 200, output_tokens: 75 },
},
}) + "\n"
);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 300);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].output, 125);
});
it("should do full re-read when file shrinks (compaction rewrite)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 500, output_tokens: 200 },
},
},
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 300, output_tokens: 100 },
},
},
]);
const cache = new TranscriptCache();
cache.extract(file);
// Simulate compaction — file is rewritten with fewer entries + summary
writeJsonl(file, [
{ isCompactSummary: true, uuid: "abc-123", timestamp: "2026-03-20T10:00:00Z" },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 50, output_tokens: 20 },
},
},
]);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 50);
assert.strictEqual(r2.compaction.count, 1);
assert.strictEqual(r2.compaction.entries[0].uuid, "abc-123");
});
it("should return null for non-existent file", () => {
const cache = new TranscriptCache();
assert.strictEqual(cache.extract("/nonexistent/file.jsonl"), null);
assert.strictEqual(cache.extract(null), null);
assert.strictEqual(cache.extract(""), null);
});
it("should expose compaction entries via extractCompactions()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{ isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 50, output_tokens: 20 },
},
},
{ isCompactSummary: true, uuid: "c2", timestamp: "2026-03-20T10:00:00Z" },
]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.strictEqual(compactions.length, 2);
assert.strictEqual(compactions[0].uuid, "c1");
assert.strictEqual(compactions[1].uuid, "c2");
});
it("should handle multiple models in same file", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{
message: {
model: "claude-opus-4-20250514",
usage: { input_tokens: 500, output_tokens: 200 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].input, 100);
assert.strictEqual(result.tokensByModel["claude-opus-4-20250514"].input, 500);
});
it("should skip <synthetic> model entries", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "<synthetic>", usage: { input_tokens: 999, output_tokens: 999 } } },
{
message: {
model: "claude-sonnet-4-20250514",
usage: { input_tokens: 100, output_tokens: 50 },
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(Object.keys(result.tokensByModel).length, 1);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].input, 100);
});
it("should handle cache_read and cache_write tokens", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
message: {
model: "claude-sonnet-4-20250514",
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 30,
cache_creation_input_tokens: 15,
},
},
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].cacheRead, 30);
assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].cacheWrite, 15);
});
it("should remove entry on invalidate()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
]);
const cache = new TranscriptCache();
cache.extract(file);
assert.strictEqual(cache.size, 1);
cache.invalidate(file);
assert.strictEqual(cache.size, 0);
});
it("should clear all entries", () => {
const file1 = path.join(tmpDir, "s1.jsonl");
const file2 = path.join(tmpDir, "s2.jsonl");
writeJsonl(file1, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
writeJsonl(file2, [
{ message: { model: "m1", usage: { input_tokens: 20, output_tokens: 10 } } },
]);
const cache = new TranscriptCache();
cache.extract(file1);
cache.extract(file2);
assert.strictEqual(cache.size, 2);
cache.clear();
assert.strictEqual(cache.size, 0);
});
it("should return correct stats()", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
cache.extract(file);
const stats = cache.stats();
assert.strictEqual(stats.entries, 1);
assert.strictEqual(stats.paths.length, 1);
assert.strictEqual(stats.paths[0], file);
});
it("should only read new bytes on incremental update", () => {
const file = path.join(tmpDir, "session.jsonl");
const line1 =
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } },
}) + "\n";
fs.writeFileSync(file, line1);
const cache = new TranscriptCache();
cache.extract(file);
// Append a second line
const line2 =
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 200, output_tokens: 75 } },
}) + "\n";
fs.appendFileSync(file, line2);
const r2 = cache.extract(file);
assert.strictEqual(r2.tokensByModel["m1"].input, 300);
// Verify bytesRead advanced to full file size
const entry = cache._cache.get(file);
assert.strictEqual(entry.bytesRead, Buffer.byteLength(line1 + line2, "utf8"));
});
it("should handle incremental read adding new compaction entries", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
]);
const cache = new TranscriptCache();
const r1 = cache.extract(file);
assert.strictEqual(r1.compaction, null);
// Append a compaction entry
fs.appendFileSync(
file,
JSON.stringify({ isCompactSummary: true, uuid: "new-c", timestamp: "2026-03-20T12:00:00Z" }) +
"\n"
);
const r2 = cache.extract(file);
assert.strictEqual(r2.compaction.count, 1);
assert.strictEqual(r2.compaction.entries[0].uuid, "new-c");
});
it("should return null for empty file", () => {
const file = path.join(tmpDir, "empty.jsonl");
fs.writeFileSync(file, "");
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file), null);
});
it("should skip malformed JSON lines gracefully", () => {
const file = path.join(tmpDir, "session.jsonl");
fs.writeFileSync(
file,
[
"not valid json",
JSON.stringify({
message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } },
}),
"{broken",
].join("\n") + "\n"
);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.tokensByModel["m1"].input, 100);
});
it("should evict oldest entries when exceeding maxEntries", () => {
const cache = new TranscriptCache(3); // max 3 entries
const files = [];
for (let i = 0; i < 5; i++) {
const file = path.join(tmpDir, `s${i}.jsonl`);
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: i * 10, output_tokens: i * 5 } } },
]);
files.push(file);
}
// Fill cache with 5 entries, but max is 3
for (const f of files) cache.extract(f);
assert.strictEqual(cache.size, 3);
// Oldest two (s0, s1) should be evicted; newest three (s2, s3, s4) remain
const stats = cache.stats();
assert.ok(!stats.paths.includes(files[0]), "oldest entry s0 should be evicted");
assert.ok(!stats.paths.includes(files[1]), "second-oldest entry s1 should be evicted");
assert.ok(stats.paths.includes(files[2]), "s2 should remain");
assert.ok(stats.paths.includes(files[3]), "s3 should remain");
assert.ok(stats.paths.includes(files[4]), "s4 should remain");
});
it("should refresh LRU order on access", () => {
const cache = new TranscriptCache(3);
const files = [];
for (let i = 0; i < 3; i++) {
const file = path.join(tmpDir, `lru${i}.jsonl`);
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
files.push(file);
cache.extract(file);
}
// Access file[0] again (moves it to most-recently-used)
fs.appendFileSync(
files[0],
JSON.stringify({ message: { model: "m1", usage: { input_tokens: 5, output_tokens: 2 } } }) +
"\n"
);
cache.extract(files[0]);
// Add a new file — should evict file[1] (now the oldest), not file[0]
const newFile = path.join(tmpDir, "lru_new.jsonl");
writeJsonl(newFile, [
{ message: { model: "m1", usage: { input_tokens: 1, output_tokens: 1 } } },
]);
cache.extract(newFile);
assert.strictEqual(cache.size, 3);
const stats = cache.stats();
assert.ok(stats.paths.includes(files[0]), "recently accessed file should remain");
assert.ok(!stats.paths.includes(files[1]), "oldest untouched file should be evicted");
assert.ok(stats.paths.includes(files[2]), "file[2] should remain");
assert.ok(stats.paths.includes(newFile), "new file should be present");
});
it("should return defensive copy from extractCompactions", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.strictEqual(compactions.length, 1);
// Mutate returned array — should NOT affect cache
compactions.push({ uuid: "fake", timestamp: null });
compactions[0].uuid = "mutated";
const compactions2 = cache.extractCompactions(file);
assert.strictEqual(compactions2.length, 1);
assert.strictEqual(compactions2[0].uuid, "c1");
});
it("should return empty array from extractCompactions for file with no compactions", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const compactions = cache.extractCompactions(file);
assert.deepStrictEqual(compactions, []);
});
it("should return empty array from extractCompactions for non-existent file", () => {
const cache = new TranscriptCache();
const compactions = cache.extractCompactions("/nonexistent.jsonl");
assert.deepStrictEqual(compactions, []);
});
it("should capture lastInterruptTs from an Esc-interrupt entry (text marker)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
{
type: "user",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T12:00:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T12:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should capture lastInterruptTs via the interruptedMessageId field", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "msg_123",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
},
timestamp: "2026-06-28T13:30:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T13:30:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should flag pendingInterrupt for an Esc pressed BEFORE any output (prompt then interrupt)", () => {
// The hard case: user submits, then cancels before the model emits anything.
// Transcript order is [user prompt, interrupt] — no assistant entry between.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
message: { role: "user", content: [{ type: "text", text: "do a big refactor" }] },
timestamp: "2026-06-28T15:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T15:00:00.001Z", // 1ms later — the real-world skew case
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.pendingInterrupt, true, "pre-output Esc must still be detected");
});
it("should flag pendingInterrupt when Esc follows assistant output", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "assistant",
message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } },
timestamp: "2026-06-28T16:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T16:00:05.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.pendingInterrupt, true);
});
it("should NOT flag pendingInterrupt when the user resumed after the interrupt", () => {
// [interrupt, new prompt] — the user came back and submitted again.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "m",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T17:00:00.000Z",
},
{
type: "user",
message: { role: "user", content: [{ type: "text", text: "actually do this instead" }] },
timestamp: "2026-06-28T17:00:30.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T17:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, false, "resuming with a new prompt clears it");
});
it("should keep the latest interrupt timestamp (append-only, last wins)", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "a",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T10:00:00.000Z",
},
{
type: "user",
interruptedMessageId: "b",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T11:00:00.000Z",
},
]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, "2026-06-28T11:00:00.000Z");
assert.strictEqual(result.pendingInterrupt, true);
});
it("should carry a newer interrupt across an incremental read", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const first = cache.extract(file);
assert.strictEqual(first.lastInterruptTs, null);
assert.strictEqual(first.pendingInterrupt, false);
// Append an interrupt entry → incremental read path must surface it.
fs.appendFileSync(
file,
JSON.stringify({
type: "user",
interruptedMessageId: "x",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T14:00:00.000Z",
}) + "\n"
);
const second = cache.extract(file);
assert.strictEqual(second.lastInterruptTs, "2026-06-28T14:00:00.000Z");
assert.strictEqual(second.pendingInterrupt, true);
});
it("should clear pendingInterrupt across an incremental read when the user resumes", () => {
// First read sees a tail interrupt; a later prompt appended in the next
// chunk must flip pendingInterrupt back to false via the merge path.
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
{
type: "user",
interruptedMessageId: "x",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
},
timestamp: "2026-06-28T18:00:00.000Z",
},
]);
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file).pendingInterrupt, true);
fs.appendFileSync(
file,
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "text", text: "resume" }] },
timestamp: "2026-06-28T18:01:00.000Z",
}) + "\n"
);
assert.strictEqual(cache.extract(file).pendingInterrupt, false, "incremental resume clears it");
});
it("should leave lastInterruptTs null and pendingInterrupt false when there is no interrupt", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);
const cache = new TranscriptCache();
const result = cache.extract(file);
assert.strictEqual(result.lastInterruptTs, null);
assert.strictEqual(result.pendingInterrupt, false);
});
});
+320
View File
@@ -0,0 +1,320 @@
/**
* @file Rules-based alerting engine. Evaluates user-defined alert rules against
* live activity: event-driven rules (event_pattern, token_threshold) run on
* every hook ingest, time-based rules (inactivity, status_duration) run on a
* periodic sweep. Fired alerts are persisted to alert_events with per-scope
* cooldown dedup and broadcast to clients as `alert_triggered`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { db, stmts } = require("../db");
const { broadcast } = require("../websocket");
const RULE_TYPES = ["event_pattern", "inactivity", "status_duration", "token_threshold"];
const AGENT_STATUSES = ["working", "waiting"];
// Enabled-rules cache. Hook ingest is hot — re-querying alert_rules on every
// event would be wasted work since rules only change through the CRUD routes,
// which call invalidateRuleCache().
let rulesCache = null;
function invalidateRuleCache() {
rulesCache = null;
}
function loadEnabledRules() {
if (rulesCache) return rulesCache;
rulesCache = stmts.listEnabledAlertRules.all().map((row) => {
let config = {};
try {
config = JSON.parse(row.config || "{}");
} catch {
/* tolerate hand-edited bad JSON — rule simply never matches */
}
return { ...row, config };
});
return rulesCache;
}
/**
* Validate and normalize a rule config for its type. Returns
* `{ ok: true, config }` with defaults applied, or `{ ok: false, error }`.
*/
function validateRuleConfig(ruleType, config) {
if (!RULE_TYPES.includes(ruleType)) {
return { ok: false, error: `rule_type must be one of: ${RULE_TYPES.join(", ")}` };
}
const cfg = config && typeof config === "object" && !Array.isArray(config) ? config : null;
if (!cfg) return { ok: false, error: "config must be an object" };
const num = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null);
switch (ruleType) {
case "event_pattern": {
const out = {};
for (const key of ["event_type", "tool_name", "summary_contains"]) {
if (cfg[key] != null) {
if (typeof cfg[key] !== "string" || !cfg[key].trim()) {
return { ok: false, error: `${key} must be a non-empty string` };
}
out[key] = cfg[key].trim();
}
}
if (!out.event_type && !out.tool_name && !out.summary_contains) {
return {
ok: false,
error: "event_pattern needs at least one of event_type, tool_name, summary_contains",
};
}
const count = cfg.count == null ? 1 : num(cfg.count);
if (!count || !Number.isInteger(count)) {
return { ok: false, error: "count must be a positive integer" };
}
out.count = count;
if (count > 1) {
const window = cfg.window_minutes == null ? 5 : num(cfg.window_minutes);
if (!window) return { ok: false, error: "window_minutes must be a positive number" };
out.window_minutes = window;
}
return { ok: true, config: out };
}
case "inactivity": {
const minutes = num(cfg.minutes);
if (!minutes) return { ok: false, error: "minutes must be a positive number" };
return { ok: true, config: { minutes } };
}
case "status_duration": {
if (!AGENT_STATUSES.includes(cfg.status)) {
return { ok: false, error: `status must be one of: ${AGENT_STATUSES.join(", ")}` };
}
const minutes = num(cfg.minutes);
if (!minutes) return { ok: false, error: "minutes must be a positive number" };
return { ok: true, config: { status: cfg.status, minutes } };
}
case "token_threshold": {
const total = num(cfg.total_tokens);
if (!total || !Number.isInteger(total)) {
return { ok: false, error: "total_tokens must be a positive integer" };
}
return { ok: true, config: { total_tokens: total } };
}
default:
return { ok: false, error: "unsupported rule_type" };
}
}
/**
* Fire an alert unless the same rule already fired for the same scope inside
* its cooldown window. Persists the alert row and broadcasts it. Returns the
* inserted row, or null when suppressed by cooldown.
*/
function fireAlert(rule, { sessionId = null, agentId = null, message, details = null }) {
const last = stmts.lastAlertFor.get(rule.id, sessionId, agentId);
if (last) {
const elapsedMs = Date.now() - new Date(last.triggered_at).getTime();
if (elapsedMs < rule.cooldown_seconds * 1000) return null;
}
const info = stmts.insertAlertEvent.run(
rule.id,
rule.name,
rule.rule_type,
sessionId,
agentId,
message,
details ? JSON.stringify(details) : null
);
const alert = stmts.getAlertEvent.get(info.lastInsertRowid);
broadcast("alert_triggered", alert);
// Fan out to configured webhook targets. Detached and fail-safe — webhook
// delivery must never slow or break alert firing. Lazy-required to keep the
// module graph acyclic and tolerate any load-order edge case.
try {
const { dispatchAlert } = require("./webhooks");
Promise.resolve(dispatchAlert(alert)).catch(() => {});
} catch (err) {
console.warn("[ALERTS] webhook dispatch failed:", err?.message || err);
}
return alert;
}
// Dynamic count-in-window queries vary by which pattern fields a rule sets;
// cache prepared statements by their SQL so hot rules don't re-prepare.
const countStmtCache = new Map();
function countMatchingEvents(sessionId, cfg) {
const where = ["session_id = ?", "created_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)"];
const params = [sessionId, `-${cfg.window_minutes * 60} seconds`];
if (cfg.event_type) {
where.push("event_type = ?");
params.push(cfg.event_type);
}
if (cfg.tool_name) {
where.push("tool_name = ?");
params.push(cfg.tool_name);
}
if (cfg.summary_contains) {
where.push("LOWER(COALESCE(summary, '')) LIKE ?");
params.push(`%${cfg.summary_contains.toLowerCase()}%`);
}
const sql = `SELECT COUNT(*) as count FROM events WHERE ${where.join(" AND ")}`;
let stmt = countStmtCache.get(sql);
if (!stmt) {
stmt = db.prepare(sql);
countStmtCache.set(sql, stmt);
}
return stmt.get(...params).count;
}
function matchesPattern(event, cfg) {
if (cfg.event_type && event.event_type !== cfg.event_type) return false;
if (cfg.tool_name && event.tool_name !== cfg.tool_name) return false;
if (
cfg.summary_contains &&
!(event.summary || "").toLowerCase().includes(cfg.summary_contains.toLowerCase())
) {
return false;
}
return true;
}
// Token totals only move on hooks that read the transcript — skip the SUM
// query for the rest of the event stream.
const TOKEN_BEARING_EVENTS = new Set(["PostToolUse", "Stop", "SubagentStop", "SessionEnd"]);
// Sweep queries are static — prepare once at module load instead of on every
// 60s tick. The time window arrives as a strftime modifier parameter.
const staleSessionsStmt = db.prepare(
`SELECT id, name FROM sessions
WHERE status = 'active'
AND updated_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`
);
const stuckAgentsStmt = db.prepare(
`SELECT a.id, a.session_id, a.name FROM agents a
JOIN sessions s ON s.id = a.session_id
WHERE s.status = 'active' AND a.status = ?
AND a.updated_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`
);
/**
* Evaluate event-driven rules against one freshly ingested event. Must never
* throw — hook ingestion stays fail-safe regardless of rule misconfiguration.
*/
function evaluateEvent(event) {
if (!event || !event.session_id) return;
let rules;
try {
rules = loadEnabledRules();
} catch (err) {
console.warn("[ALERTS] rule load failed:", err?.message || err);
return;
}
for (const rule of rules) {
try {
if (rule.rule_type === "event_pattern") {
const cfg = rule.config;
if (!matchesPattern(event, cfg)) continue;
if (cfg.count > 1) {
const seen = countMatchingEvents(event.session_id, cfg);
if (seen < cfg.count) continue;
fireAlert(rule, {
sessionId: event.session_id,
agentId: event.agent_id || null,
message: `${rule.name}: ${seen} matching events in ${cfg.window_minutes} min (threshold ${cfg.count})`,
details: { matched: cfg, observed_count: seen, last_event_type: event.event_type },
});
} else {
fireAlert(rule, {
sessionId: event.session_id,
agentId: event.agent_id || null,
message: `${rule.name}: event matched (${event.event_type}${event.tool_name ? ` · ${event.tool_name}` : ""})`,
details: { matched: cfg, summary: event.summary || null },
});
}
} else if (rule.rule_type === "token_threshold") {
if (!TOKEN_BEARING_EVENTS.has(event.event_type)) continue;
const totals = stmts.sessionTokenTotals.get(event.session_id);
const total =
totals.input_tokens +
totals.output_tokens +
totals.cache_read_tokens +
totals.cache_write_tokens;
if (total < rule.config.total_tokens) continue;
fireAlert(rule, {
sessionId: event.session_id,
message: `${rule.name}: session used ${total.toLocaleString()} tokens (threshold ${rule.config.total_tokens.toLocaleString()})`,
details: { total_tokens: total, threshold: rule.config.total_tokens },
});
}
} catch (err) {
console.warn(`[ALERTS] rule "${rule.name}" evaluation failed:`, err?.message || err);
}
}
}
/**
* Evaluate time-based rules (inactivity, status_duration). Called by the
* periodic sweep; exported so tests can invoke it deterministically.
*/
function sweepTimeRules() {
let rules;
try {
rules = loadEnabledRules();
} catch (err) {
console.warn("[ALERTS] rule load failed:", err?.message || err);
return;
}
for (const rule of rules) {
try {
if (rule.rule_type === "inactivity") {
// sessions.updated_at is bumped on every ingested event (touchSession),
// so "stale updated_at on an active session" ≡ "no events for N min".
const stale = staleSessionsStmt.all(`-${rule.config.minutes * 60} seconds`);
for (const session of stale) {
fireAlert(rule, {
sessionId: session.id,
message: `${rule.name}: no activity on "${session.name || session.id}" for ${rule.config.minutes} min`,
details: { minutes: rule.config.minutes },
});
}
} else if (rule.rule_type === "status_duration") {
// agents.updated_at moves on any agent update (status flips, tool
// changes), so this detects agents *stuck* in a status with no
// activity — the hung-agent case the rule exists for.
const stuck = stuckAgentsStmt.all(
rule.config.status,
`-${rule.config.minutes * 60} seconds`
);
for (const agent of stuck) {
fireAlert(rule, {
sessionId: agent.session_id,
agentId: agent.id,
message: `${rule.name}: agent "${agent.name}" stuck in ${rule.config.status} for ${rule.config.minutes} min`,
details: { status: rule.config.status, minutes: rule.config.minutes },
});
}
}
} catch (err) {
console.warn(`[ALERTS] rule "${rule.name}" sweep failed:`, err?.message || err);
}
}
}
// Periodic sweep for the time-based rules. unref'd so it never keeps the
// process (or the test runner) alive — same pattern as the hooks watchdog.
const SWEEP_INTERVAL_MS = 60_000;
const sweepTimer = setInterval(sweepTimeRules, SWEEP_INTERVAL_MS);
if (sweepTimer.unref) sweepTimer.unref();
module.exports = {
RULE_TYPES,
validateRuleConfig,
evaluateEvent,
sweepTimeRules,
fireAlert,
invalidateRuleCache,
};
+270
View File
@@ -0,0 +1,270 @@
/**
* @file Safe archive extraction helpers for the history-import feature.
*
* Supports `.zip`, `.tar`, `.tar.gz`, `.tgz`, and plain `.gz` (single-file).
* Every entry is validated against path traversal (no absolute paths, no
* `..` segments) and resolved relative to the target directory. Non-regular
* entries (symlinks, devices, hardlinks) are skipped rather than extracted.
*
* All functions are async and never throw on unknown formats — they return
* `{ extracted: number, skipped: number }` so routes can surface counts.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const zlib = require("zlib");
const { pipeline } = require("stream/promises");
const crypto = require("crypto");
/**
* Maximum total bytes any single archive is allowed to expand to during
* extraction. Tunable via env so deployments with huge legitimate archives
* can raise it; the default (4 GB) is generous for real-world transcript
* bundles but low enough to stop most zip-bomb attacks from filling disk.
*/
const MAX_EXTRACT_BYTES = parseInt(
process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES || String(4 * 1024 * 1024 * 1024),
10
);
class ExtractionLimitError extends Error {
constructor(limit) {
super(`Archive exceeded the ${limit}-byte extraction limit (possible zip bomb).`);
this.code = "EXTRACTION_LIMIT_EXCEEDED";
}
}
/**
* True if `child` is contained within `parent` after normalization.
* Used to reject archive entries that would escape the extraction root.
*/
function isPathInside(parent, child) {
const p = path.resolve(parent) + path.sep;
const c = path.resolve(child);
return c === path.resolve(parent) || c.startsWith(p);
}
/**
* Normalize an archive entry name: strip leading slashes, collapse `..`,
* reject if it escapes the root.
*/
function safeJoin(root, entryName) {
const cleaned = String(entryName).replace(/^[/\\]+/, "");
if (!cleaned || cleaned === "." || cleaned === "..") return null;
const joined = path.join(root, cleaned);
if (!isPathInside(root, joined)) return null;
return joined;
}
/**
* Create a unique temp directory for extraction under the OS tmpdir.
* Caller is responsible for cleanup via `rmTempDir`.
*/
function mkTempDir(prefix = "ccam-import-") {
const dir = path.join(os.tmpdir(), prefix + crypto.randomBytes(6).toString("hex"));
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function rmTempDir(dir) {
if (!dir) return;
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* non-fatal */
}
}
/**
* Extract a `.zip` archive into `destDir` using adm-zip.
* Lazily required so the dependency is optional at install time for users
* who don't need archive upload.
*/
async function extractZip(zipPath, destDir) {
let AdmZip;
try {
AdmZip = require("adm-zip");
} catch (err) {
throw new Error(
"adm-zip is required to extract .zip archives. Run `npm install` to pick up new deps."
);
}
const zip = new AdmZip(zipPath);
const entries = zip.getEntries();
// Pre-check declared uncompressed sizes so we reject obvious zip bombs
// before materializing any bytes to disk.
let declared = 0;
for (const entry of entries) {
if (!entry.isDirectory) declared += entry.header?.size || 0;
}
if (declared > MAX_EXTRACT_BYTES) throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
let extracted = 0;
let skipped = 0;
let writtenBytes = 0;
for (const entry of entries) {
if (entry.isDirectory) continue;
const target = safeJoin(destDir, entry.entryName);
if (!target) {
skipped++;
continue;
}
const data = entry.getData();
writtenBytes += data.length;
if (writtenBytes > MAX_EXTRACT_BYTES) throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
fs.mkdirSync(path.dirname(target), { recursive: true });
try {
fs.writeFileSync(target, data);
extracted++;
} catch {
skipped++;
}
}
return { extracted, skipped };
}
/**
* Extract a `.tar`, `.tar.gz`, or `.tgz` archive into `destDir`.
* Uses the `tar` package in streaming mode with `onentry` filter so we can
* enforce path containment ourselves rather than relying on the lib's flags.
*/
async function extractTar(tarPath, destDir) {
let tar;
try {
tar = require("tar");
} catch {
throw new Error(
"tar is required to extract .tar/.tar.gz archives. Run `npm install` to pick up new deps."
);
}
let extracted = 0;
let skipped = 0;
let writtenBytes = 0;
await tar.x({
file: tarPath,
cwd: destDir,
strict: false,
preservePaths: false,
filter: (entryPath, entry) => {
if (entry.type && entry.type !== "File" && entry.type !== "Directory") {
skipped++;
return false;
}
const target = safeJoin(destDir, entryPath);
if (!target) {
skipped++;
return false;
}
if (entry.type === "File") {
writtenBytes += entry.size || 0;
if (writtenBytes > MAX_EXTRACT_BYTES) {
// Surfacing the limit as a throw aborts tar.x; callers will see
// ExtractionLimitError in the catch path.
throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
}
extracted++;
}
return true;
},
});
return { extracted, skipped };
}
/**
* Decompress a plain `.gz` file (not a tar archive) into `destDir`, reusing
* the original filename with `.gz` stripped. Useful when a single JSONL was
* gzipped for transfer.
*/
async function extractGzSingle(gzPath, destDir) {
const base = path.basename(gzPath).replace(/\.gz$/i, "") || "decompressed.jsonl";
const target = safeJoin(destDir, base);
if (!target) return { extracted: 0, skipped: 1 };
fs.mkdirSync(path.dirname(target), { recursive: true });
// Count decompressed bytes as they flow through gunzip; abort if we blow
// past the extraction limit (defends against single-file gzip bombs).
let written = 0;
const { Transform } = require("stream");
const limiter = new Transform({
transform(chunk, _enc, cb) {
written += chunk.length;
if (written > MAX_EXTRACT_BYTES) {
cb(new ExtractionLimitError(MAX_EXTRACT_BYTES));
return;
}
cb(null, chunk);
},
});
await pipeline(
fs.createReadStream(gzPath),
zlib.createGunzip(),
limiter,
fs.createWriteStream(target)
);
return { extracted: 1, skipped: 0 };
}
/**
* Detect the archive kind from the filename. Returns one of:
* "zip" | "tar" | "tgz" | "gz" | "jsonl" | "meta" | "unknown"
*/
function detectKind(filename) {
const lower = filename.toLowerCase();
if (lower.endsWith(".zip")) return "zip";
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tgz";
if (lower.endsWith(".tar")) return "tar";
if (lower.endsWith(".meta.json")) return "meta";
if (lower.endsWith(".jsonl")) return "jsonl";
if (lower.endsWith(".gz")) return "gz";
return "unknown";
}
/**
* Dispatch to the right extractor based on filename. For plain `.jsonl` and
* `.meta.json` files we copy them through into `destDir`. Unknown files are
* skipped so users can drop mixed content without failures.
*/
async function extractInto(srcPath, destDir, originalName) {
const name = originalName || path.basename(srcPath);
const kind = detectKind(name);
switch (kind) {
case "zip":
return extractZip(srcPath, destDir);
case "tar":
case "tgz":
return extractTar(srcPath, destDir);
case "gz":
return extractGzSingle(srcPath, destDir);
case "jsonl":
case "meta": {
const target = safeJoin(destDir, name);
if (!target) return { extracted: 0, skipped: 1 };
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(srcPath, target);
return { extracted: 1, skipped: 0 };
}
default:
return { extracted: 0, skipped: 1 };
}
}
module.exports = {
mkTempDir,
rmTempDir,
extractInto,
extractZip,
extractTar,
extractGzSingle,
detectKind,
safeJoin,
isPathInside,
ExtractionLimitError,
MAX_EXTRACT_BYTES,
};
+831
View File
@@ -0,0 +1,831 @@
/**
* @file cc-discovery.js
* @description Read-only discovery of Claude Code configuration surfaces
* (skills, subagents, slash commands, output styles, plugins, marketplaces,
* MCP servers, hooks, settings, memory, keybindings, statusline, hook
* scripts). Powers the Claude Config Explorer page. All operations are pure
* file reads — never writes.
*
* Path containment: every read resolves under getClaudeHome(),
* getProjectClaudeDir(), or getProjectRoot() (for CLAUDE.md). Reads outside
* those roots return null. Settings are redacted of secret-like keys before
* returning.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { getClaudeHome } = require("./claude-home");
const MAX_FILE_BYTES = 256 * 1024; // skip reads above this; truncate body in details
const REDACT_KEY_RE = /token|secret|password|api[_-]?key|auth/i;
function getProjectRoot(cwd) {
return path.resolve(cwd || process.cwd());
}
function getProjectClaudeDir(cwd) {
return path.join(getProjectRoot(cwd), ".claude");
}
function getClaudeJsonPath() {
// ~/.claude.json sits beside ~/.claude/, NOT inside it. Resolve from $HOME
// so a CLAUDE_HOME override doesn't accidentally relocate it.
return path.join(os.homedir(), ".claude.json");
}
/**
* True if `target` is contained within `root` (after symlink-aware resolve).
* Defends the /file endpoint against `..` traversal and absolute-path tricks.
*/
function isUnder(root, target) {
const r = path.resolve(root);
const t = path.resolve(target);
if (t === r) return true;
return t.startsWith(r + path.sep);
}
function readJson(absPath) {
try {
const raw = fs.readFileSync(absPath, "utf8");
return { ok: true, data: JSON.parse(raw), raw };
} catch (err) {
if (err && err.code === "ENOENT") return { ok: false, missing: true };
return { ok: false, error: err.message };
}
}
function redactSettings(value) {
if (Array.isArray(value)) return value.map(redactSettings);
if (value && typeof value === "object") {
const out = {};
for (const [k, v] of Object.entries(value)) {
if (typeof v === "string" && REDACT_KEY_RE.test(k)) {
out[k] = "<redacted>";
} else {
out[k] = redactSettings(v);
}
}
return out;
}
return value;
}
/**
* Minimal YAML-frontmatter parser. Handles `---\n<key>: <value>\n---\n<body>`.
* Quoted strings (single + double) are stripped; multi-line values are
* preserved as raw strings. Anything we can't parse is returned as null
* frontmatter — the body is still readable.
*/
function parseFrontmatter(text) {
if (typeof text !== "string") return { frontmatter: null, body: "" };
if (!text.startsWith("---")) return { frontmatter: null, body: text };
const end = text.indexOf("\n---", 3);
if (end < 0) return { frontmatter: null, body: text };
const head = text.slice(3, end).replace(/^\s*\n/, "");
const body = text.slice(end + 4).replace(/^\s*\n/, "");
const fm = {};
let currentKey = null;
for (const rawLine of head.split("\n")) {
const line = rawLine.replace(/\s+$/, "");
if (!line.trim()) continue;
// continuation of a multiline value
if (currentKey && /^\s/.test(rawLine)) {
fm[currentKey] += "\n" + rawLine.trim();
continue;
}
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!m) {
currentKey = null;
continue;
}
currentKey = m[1];
let v = m[2];
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
fm[currentKey] = v;
}
return { frontmatter: fm, body };
}
function safeReadText(absPath) {
try {
const stat = fs.statSync(absPath);
if (!stat.isFile()) return null;
if (stat.size > MAX_FILE_BYTES) {
return {
truncated: true,
size: stat.size,
text: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES),
mtime: stat.mtimeMs,
};
}
return {
truncated: false,
size: stat.size,
text: fs.readFileSync(absPath, "utf8"),
mtime: stat.mtimeMs,
};
} catch {
return null;
}
}
function listDir(absPath) {
try {
return fs.readdirSync(absPath, { withFileTypes: true });
} catch {
return [];
}
}
/**
* True if `ent` is a directory, OR a symlink that resolves to one.
* Dirent.isDirectory() returns false for symlinks even when they point at a
* directory (e.g. a skill installed via `ln -s` so it can live in a git
* repo) — this follows the link with statSync so those aren't skipped.
* Broken symlinks are treated as non-directories rather than throwing.
*/
function isDirLike(ent, absPath) {
if (ent.isDirectory()) return true;
if (!ent.isSymbolicLink()) return false;
try {
return fs.statSync(absPath).isDirectory();
} catch {
return false;
}
}
/**
* File counterpart of {@link isDirLike}: true if `ent` is a regular file, OR a
* symlink that resolves to one. Same Dirent quirk — `ent.isFile()` returns
* false for a symlink even when it points at a file, so agents/commands/hook
* scripts installed via `ln -s` were invisible to the Config Explorer while
* Claude Code itself resolves and uses them. Broken symlinks are treated as
* non-files rather than throwing.
*/
function isFileLike(ent, absPath) {
if (ent.isFile()) return true;
if (!ent.isSymbolicLink()) return false;
try {
return fs.statSync(absPath).isFile();
} catch {
return false;
}
}
// ── Skills ──────────────────────────────────────────────────────────────
function readSkillsAt(scope, claudeDir) {
const dir = path.join(claudeDir, "skills");
const entries = listDir(dir);
const skills = [];
for (const ent of entries) {
const skillDir = path.join(dir, ent.name);
if (!isDirLike(ent, skillDir)) continue;
const skillFile = path.join(skillDir, "SKILL.md");
const read = safeReadText(skillFile);
if (!read) continue;
const { frontmatter, body } = parseFrontmatter(read.text);
skills.push({
scope,
name: ent.name,
path: skillDir,
file: skillFile,
size: read.size,
mtime: read.mtime,
truncated: read.truncated,
frontmatter: frontmatter || {},
preview: body.slice(0, 320),
});
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
function readSkills(opts = {}) {
const out = [];
if (opts.scope !== "project") {
out.push(...readSkillsAt("user", getClaudeHome()));
}
if (opts.scope !== "user") {
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
}
return out;
}
// ── Single-file MD surfaces (agents, commands, output styles) ──────────
function readMdFilesAt(scope, claudeDir, subdir) {
const dir = path.join(claudeDir, subdir);
const entries = listDir(dir);
const out = [];
for (const ent of entries) {
if (!ent.name.endsWith(".md")) continue;
const file = path.join(dir, ent.name);
if (!isFileLike(ent, file)) continue;
const read = safeReadText(file);
if (!read) continue;
const { frontmatter, body } = parseFrontmatter(read.text);
out.push({
scope,
name: ent.name.replace(/\.md$/, ""),
file,
size: read.size,
mtime: read.mtime,
truncated: read.truncated,
frontmatter: frontmatter || {},
preview: body.slice(0, 320),
});
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
function readSimpleMdSurface(subdir) {
return (opts = {}) => {
const out = [];
if (opts.scope !== "project") {
out.push(...readMdFilesAt("user", getClaudeHome(), subdir));
}
if (opts.scope !== "user") {
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
}
return out;
};
}
const readAgents = readSimpleMdSurface("agents");
const readCommands = readSimpleMdSurface("commands");
const readOutputStyles = readSimpleMdSurface("output-styles");
// ── Plugins ─────────────────────────────────────────────────────────────
function countMdIn(dir) {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.name.endsWith(".md") && isFileLike(e, path.join(dir, e.name))).length;
} catch {
return 0;
}
}
function countSkillDirsIn(dir) {
try {
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => {
if (!isDirLike(e, path.join(dir, e.name))) return false;
try {
return fs.statSync(path.join(dir, e.name, "SKILL.md")).isFile();
} catch {
return false;
}
}).length;
} catch {
return 0;
}
}
function readPluginContributions(installPath) {
if (!installPath) return null;
let pluginJson = null;
try {
const raw = fs.readFileSync(path.join(installPath, ".claude-plugin", "plugin.json"), "utf8");
pluginJson = JSON.parse(raw);
} catch {
pluginJson = null;
}
return {
skills: countSkillDirsIn(path.join(installPath, "skills")),
agents: countMdIn(path.join(installPath, "agents")),
commands: countMdIn(path.join(installPath, "commands")),
outputStyles: countMdIn(path.join(installPath, "output-styles")),
hooks: (() => {
try {
return fs
.readdirSync(path.join(installPath, "hooks"), { withFileTypes: true })
.filter((e) => isFileLike(e, path.join(installPath, "hooks", e.name))).length;
} catch {
return 0;
}
})(),
pluginJson,
};
}
function readEnabledPluginsMap() {
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
if (!userSettings.ok || !userSettings.data) return {};
const ep = userSettings.data.enabledPlugins;
return ep && typeof ep === "object" ? ep : {};
}
function readPlugins() {
const home = getClaudeHome();
const manifestPath = path.join(home, "plugins", "installed_plugins.json");
const manifest = readJson(manifestPath);
const enabledMap = readEnabledPluginsMap();
const plugins = [];
if (manifest.ok && manifest.data && manifest.data.plugins) {
for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) {
const arr = Array.isArray(instances) ? instances : [instances];
for (const inst of arr) {
const installPath = inst.installPath;
let exists = false;
try {
exists = installPath ? fs.statSync(installPath).isDirectory() : false;
} catch {
exists = false;
}
const contributes = exists ? readPluginContributions(installPath) : null;
// enabledPlugins map keys can be just the plugin name OR "<name>@<marketplace>"
const enabledByKey = enabledMap[pluginKey];
const enabledByName = enabledMap[pluginKey.split("@")[0]];
const enabled =
enabledByKey === true || enabledByName === true
? true
: enabledByKey === false || enabledByName === false
? false
: null;
plugins.push({
key: pluginKey,
name: pluginKey.split("@")[0],
marketplace: pluginKey.includes("@") ? pluginKey.split("@")[1] : null,
scope: inst.scope || "user",
version: inst.version || null,
installPath: installPath || null,
installedAt: inst.installedAt || null,
lastUpdated: inst.lastUpdated || null,
gitCommitSha: inst.gitCommitSha || null,
installPathExists: exists,
enabled,
contributes,
});
}
}
}
return {
manifestPath,
manifestExists: manifest.ok,
plugins: plugins.sort((a, b) => a.key.localeCompare(b.key)),
};
}
// ── MCP servers ────────────────────────────────────────────────────────
function readMcpServers(opts = {}) {
const out = { user: [], projectScoped: [] };
// ~/.claude.json is the primary CLI state file; mcpServers can live at the
// top level (legacy) or inside projects[<cwd>].mcpServers (per-project).
const claudeJson = readJson(getClaudeJsonPath());
if (claudeJson.ok && claudeJson.data) {
const top = claudeJson.data.mcpServers;
if (top && typeof top === "object") {
for (const [name, def] of Object.entries(top)) {
out.user.push({
name,
source: "~/.claude.json (top-level)",
...summarizeMcpDef(def),
});
}
}
const projects = claudeJson.data.projects;
if (projects && typeof projects === "object") {
const projectRoot = getProjectRoot(opts.cwd);
const projectEntry = projects[projectRoot];
if (projectEntry && projectEntry.mcpServers) {
for (const [name, def] of Object.entries(projectEntry.mcpServers)) {
out.projectScoped.push({
name,
source: `~/.claude.json (projects[${projectRoot}])`,
...summarizeMcpDef(def),
});
}
}
}
}
// Also sniff settings.json for an mcpServers key (rare but supported).
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
if (userSettings.ok && userSettings.data && userSettings.data.mcpServers) {
for (const [name, def] of Object.entries(userSettings.data.mcpServers)) {
out.user.push({
name,
source: "~/.claude/settings.json",
...summarizeMcpDef(def),
});
}
}
return out;
}
function summarizeMcpDef(def) {
if (!def || typeof def !== "object") return { kind: "unknown" };
if (def.url)
return { kind: "http", url: def.url, headers: def.headers ? Object.keys(def.headers) : [] };
if (def.command) {
return {
kind: "stdio",
command: def.command,
args: Array.isArray(def.args) ? def.args : [],
envNames: def.env && typeof def.env === "object" ? Object.keys(def.env) : [],
};
}
return { kind: "unknown" };
}
// ── Hooks (read across user + project + project-local) ─────────────────
const HOOK_EVENT_TYPES = [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"Notification",
"PreCompact",
];
function readHooks(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
{
scope: "project",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
},
{
scope: "project-local",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
},
];
const result = [];
for (const { scope, file } of sources) {
const j = readJson(file);
const entry = { scope, file, exists: j.ok, hooks: {} };
if (j.ok && j.data && j.data.hooks && typeof j.data.hooks === "object") {
for (const event of HOOK_EVENT_TYPES) {
const matchers = j.data.hooks[event];
if (!Array.isArray(matchers)) continue;
const flat = [];
for (const m of matchers) {
const matcher = m.matcher || "*";
const list = Array.isArray(m.hooks) ? m.hooks : [];
for (const h of list) {
flat.push({
matcher,
type: h.type || "command",
command: h.command || null,
timeout: h.timeout || null,
});
}
}
if (flat.length) entry.hooks[event] = flat;
}
// Also surface unknown events the user wrote
for (const [event, matchers] of Object.entries(j.data.hooks)) {
if (HOOK_EVENT_TYPES.includes(event)) continue;
if (!Array.isArray(matchers)) continue;
entry.hooks[event] = matchers;
}
}
result.push(entry);
}
return result;
}
// ── Settings ───────────────────────────────────────────────────────────
function readSettings(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
{
scope: "project",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
},
{
scope: "project-local",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
},
];
return sources.map(({ scope, file }) => {
const j = readJson(file);
if (!j.ok) return { scope, file, exists: false };
return {
scope,
file,
exists: true,
data: redactSettings(j.data),
raw_size: j.raw.length,
};
});
}
// ── Marketplaces ───────────────────────────────────────────────────────
function readMarketplaces() {
const home = getClaudeHome();
const knownPath = path.join(home, "plugins", "known_marketplaces.json");
const known = readJson(knownPath);
const out = [];
if (known.ok && known.data && typeof known.data === "object") {
for (const [name, def] of Object.entries(known.data)) {
const installLocation = def && def.installLocation;
const sourceDef = def && def.source;
let pluginCount = null;
let marketplaceJson = null;
if (installLocation) {
try {
const mfPath = path.join(installLocation, ".claude-plugin", "marketplace.json");
const raw = fs.readFileSync(mfPath, "utf8");
marketplaceJson = JSON.parse(raw);
pluginCount = Array.isArray(marketplaceJson.plugins)
? marketplaceJson.plugins.length
: null;
} catch {
/* not all marketplaces have a manifest */
}
}
out.push({
name,
source: sourceDef && typeof sourceDef === "object" ? sourceDef : null,
installLocation: installLocation || null,
lastUpdated: def && def.lastUpdated ? def.lastUpdated : null,
pluginCount,
marketplaceName: marketplaceJson?.name || null,
marketplaceDescription: marketplaceJson?.description || null,
marketplaceOwner: marketplaceJson?.owner || null,
});
}
}
return {
knownPath,
knownExists: known.ok,
items: out.sort((a, b) => a.name.localeCompare(b.name)),
};
}
// ── Keybindings ────────────────────────────────────────────────────────
function readKeybindings() {
const file = path.join(getClaudeHome(), "keybindings.json");
const j = readJson(file);
if (!j.ok) return { file, exists: false };
const data = j.data && typeof j.data === "object" ? j.data : {};
const groups = Array.isArray(data.bindings) ? data.bindings : [];
return {
file,
exists: true,
schema: data.$schema || null,
docs: data.$docs || null,
groups: groups.map((g) => ({
context: g.context || "",
bindings:
g.bindings && typeof g.bindings === "object"
? Object.entries(g.bindings).map(([key, action]) => ({ key, action: String(action) }))
: [],
})),
};
}
// ── Statusline (config + script content) ──────────────────────────────
function readStatusline() {
const userSettingsPath = path.join(getClaudeHome(), "settings.json");
const j = readJson(userSettingsPath);
const config = j.ok && j.data && j.data.statusLine ? j.data.statusLine : null;
const candidates = [
path.join(getClaudeHome(), "statusline.py"),
path.join(getClaudeHome(), "statusline-command.sh"),
];
const scripts = [];
for (const file of candidates) {
const r = safeReadText(file);
if (r) {
scripts.push({
file,
size: r.size,
mtime: r.mtime,
truncated: r.truncated,
preview: r.text.slice(0, 4000),
});
}
}
return { config, scripts };
}
// ── Hook handler scripts dir (~/.claude/hooks/) ───────────────────────
function readHookScripts() {
const dir = path.join(getClaudeHome(), "hooks");
const entries = listDir(dir);
return {
dir,
items: entries
.filter((e) => isFileLike(e, path.join(dir, e.name)))
.map((e) => {
const file = path.join(dir, e.name);
let stat;
try {
stat = fs.statSync(file);
} catch {
return null;
}
return { name: e.name, file, size: stat.size, mtime: stat.mtimeMs };
})
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name)),
};
}
// ── Memory (CLAUDE.md + per-project file-based memory) ─────────────────
// Index/manifest files inside a memory dir (MEMORY.md, INDEX-*.md) sort
// before the per-fact files so the table-of-contents shows up first.
const MEMORY_INDEX_RE = /^(MEMORY|INDEX)\b/i;
/**
* Read the two primary CLAUDE.md memory files (user + project) PLUS every
* markdown file under ~/.claude/projects/<slug>/memory/ — the common
* community pattern of a file-based agent memory store (a MEMORY.md index
* plus one file per remembered fact). The latter are emitted with
* scope "auto-memory" and carry `project` (the projects/<slug> dir name)
* and `name` (the filename) so the UI can group + label them. They are
* mutable via cc-mutate's "auto-memory" type (create/edit/delete + backup).
*/
function readMemory(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "CLAUDE.md") },
{ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") },
];
const result = [];
for (const { scope, file } of sources) {
const r = safeReadText(file);
if (!r) continue;
result.push({
scope,
file,
size: r.size,
mtime: r.mtime,
truncated: r.truncated,
preview: r.text.slice(0, 480),
});
}
// Per-project file-based memory dirs. Best-effort: a missing projects
// root, an unreadable memory dir, or a single bad file must never break
// the memory tab — every layer is wrapped so we degrade to "fewer files".
try {
const projectsRoot = path.join(getClaudeHome(), "projects");
for (const proj of fs.readdirSync(projectsRoot)) {
const memDir = path.join(projectsRoot, proj, "memory");
let files;
try {
files = fs.readdirSync(memDir);
} catch {
continue;
}
files = files
.filter((f) => f.endsWith(".md"))
.sort((a, b) => {
const rank = (f) => (MEMORY_INDEX_RE.test(f) ? 0 : 1);
return rank(a) - rank(b) || a.localeCompare(b);
});
for (const f of files) {
const file = path.join(memDir, f);
const r = safeReadText(file);
if (!r) continue;
// Per-fact memory files commonly carry YAML frontmatter (name,
// description, metadata.type) — parse it like the other MD surfaces
// so the UI can show a clean title + description instead of raw text.
const { frontmatter, body } = parseFrontmatter(r.text);
result.push({
scope: "auto-memory",
project: proj,
name: f,
isIndex: MEMORY_INDEX_RE.test(f),
file,
size: r.size,
mtime: r.mtime,
truncated: r.truncated,
frontmatter: frontmatter || {},
preview: (body || r.text).slice(0, 480),
});
}
}
} catch {
/* best-effort: never break the memory tab */
}
return result;
}
// ── Single-file body endpoint (with strict path containment) ───────────
function readFileSafe(absPath, opts = {}) {
const allowedRoots = [
getClaudeHome(),
getProjectClaudeDir(opts.cwd),
getProjectRoot(opts.cwd), // for CLAUDE.md only — caller must pass exact name
];
const resolved = path.resolve(absPath);
const inside = allowedRoots.some((root) => isUnder(root, resolved));
if (!inside) return { error: "path is outside allowed roots" };
// Extra guard: under project root we only allow CLAUDE.md (avoid leaking
// arbitrary repo files via this endpoint).
if (
isUnder(getProjectRoot(opts.cwd), resolved) &&
!isUnder(getProjectClaudeDir(opts.cwd), resolved) &&
path.basename(resolved) !== "CLAUDE.md"
) {
return { error: "only CLAUDE.md is readable from project root" };
}
const r = safeReadText(resolved);
if (!r) return { error: "file not readable" };
return { ok: true, file: resolved, ...r };
}
// ── Overview (counts + roots) ──────────────────────────────────────────
function readOverview(opts = {}) {
const skills = readSkills(opts);
const agents = readAgents(opts);
const commands = readCommands(opts);
const outputStyles = readOutputStyles(opts);
const plugins = readPlugins();
const mcp = readMcpServers(opts);
const hooks = readHooks(opts);
const settings = readSettings(opts);
const memory = readMemory(opts);
const marketplaces = readMarketplaces();
const keybindings = readKeybindings();
const countByScope = (arr) => ({
user: arr.filter((x) => x.scope === "user").length,
project: arr.filter((x) => x.scope === "project").length,
});
const enabledPlugins = plugins.plugins.filter((p) => p.enabled === true).length;
const disabledPlugins = plugins.plugins.filter((p) => p.enabled === false).length;
const keybindingTotal = keybindings.exists
? keybindings.groups.reduce((n, g) => n + g.bindings.length, 0)
: 0;
return {
roots: {
claudeHome: getClaudeHome(),
projectClaudeDir: getProjectClaudeDir(opts.cwd),
projectRoot: getProjectRoot(opts.cwd),
claudeJson: getClaudeJsonPath(),
},
counts: {
skills: countByScope(skills),
agents: countByScope(agents),
commands: countByScope(commands),
outputStyles: countByScope(outputStyles),
plugins: plugins.plugins.length,
pluginsEnabled: enabledPlugins,
pluginsDisabled: disabledPlugins,
marketplaces: marketplaces.items.length,
keybindings: keybindingTotal,
mcpServers: { user: mcp.user.length, project: mcp.projectScoped.length },
hooks: hooks.reduce(
(acc, src) => {
acc[src.scope] = Object.values(src.hooks).reduce(
(n, arr) => n + (Array.isArray(arr) ? arr.length : 0),
0
);
return acc;
},
{ user: 0, project: 0, "project-local": 0 }
),
memory: memory.length,
settingsFiles: settings.filter((s) => s.exists).length,
},
};
}
module.exports = {
// surface readers
readSkills,
readAgents,
readCommands,
readOutputStyles,
readPlugins,
readMcpServers,
readHooks,
readSettings,
readMemory,
readMarketplaces,
readKeybindings,
readStatusline,
readHookScripts,
readOverview,
readFileSafe,
// helpers exported for tests
parseFrontmatter,
redactSettings,
isUnder,
isFileLike,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
};
+535
View File
@@ -0,0 +1,535 @@
/**
* @file cc-mutate.js
* @description Mutation helpers for the Claude Config Explorer. Handles
* create / overwrite / delete on the low-risk text-file surfaces only:
* skills, subagents, slash commands, output styles, CLAUDE.md memory, and
* per-project file-based memory (~/.claude/projects/<slug>/memory/*.md).
*
* Hard constraints (do not relax without a follow-up review):
* - Plugins, MCP servers, hooks-in-settings, and settings.json files are
* NEVER touched here. Those have concurrent-write races with the live
* Claude Code CLI and need different handling.
* - Every write/delete creates a timestamped backup BEFORE the mutation.
* Backups land under <root>/cc-config-backups/<type>/, well outside the
* directories Claude Code scans, so a deleted skill cannot reappear as
* a backup-named skill.
* - Writes are atomic via temp file + fs.renameSync. Tmp is removed on
* any failure path.
* - Names are validated against a strict allowlist regex; resolved paths
* are double-checked to live under the expected root before any I/O.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const { isUnder, MAX_FILE_BYTES } = require("./cc-discovery");
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
// Auto-memory files are arbitrary flat *.md filenames inside a project's
// memory dir; the project is the ~/.claude/projects/<slug> dir name.
const MEMORY_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/i;
// Project slugs are an absolute cwd with "/" → "-", so they begin with "-".
// Allow alnum/_/- as the first char (never "." — blocks hidden/weird dirs);
// traversal is additionally blocked by the !includes("..") + isUnder guards.
const PROJECT_SLUG_RE = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/;
const TYPES = {
skills: { kind: "dir", subdir: "skills", filename: "SKILL.md" },
agents: { kind: "file", subdir: "agents", ext: ".md" },
commands: { kind: "file", subdir: "commands", ext: ".md" },
"output-styles": { kind: "file", subdir: "output-styles", ext: ".md" },
memory: { kind: "memory" }, // CLAUDE.md at root, no `name`
// Per-project file-based memory: ~/.claude/projects/<project>/memory/<name>.md.
// Keyed by (project, name); scope is irrelevant (always under CLAUDE_HOME).
"auto-memory": { kind: "auto-memory" },
};
function getProjectRoot(cwd) {
return path.resolve(cwd || process.cwd());
}
function getProjectClaudeDir(cwd) {
return path.join(getProjectRoot(cwd), ".claude");
}
function rootForScope(scope, opts = {}) {
if (scope === "user") return getClaudeHome();
if (scope === "project") return getProjectClaudeDir(opts.cwd);
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
function memoryPathForScope(scope, opts = {}) {
if (scope === "user") return path.join(getClaudeHome(), "CLAUDE.md");
if (scope === "project") return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
/**
* Resolve (and validate) the memory dir for a per-project file-based memory
* store: ~/.claude/projects/<project>/memory/. Rejects slugs that could
* traverse out of the projects root.
*/
function autoMemoryDir(project) {
if (typeof project !== "string" || !PROJECT_SLUG_RE.test(project) || project.includes("..")) {
throw makeError("EBADPROJECT", `invalid project slug: ${project}`);
}
const projectsRoot = path.join(getClaudeHome(), "projects");
const dir = path.join(projectsRoot, project, "memory");
if (!isUnder(projectsRoot, dir)) {
throw makeError("EOUTOFROOT", "project escapes the projects root");
}
return dir;
}
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Resolve the on-disk target for a (scope, type, name) tuple AND the
* containment root used for path-traversal checks.
*
* Returns:
* { kind: "file" | "dir" | "memoryFile",
* target: <abs path of file or skill dir>,
* filePath: <abs path of the actual .md file inside target>,
* containmentRoot: <abs path that must contain target> }
*/
function resolveTarget(scope, type, name, opts = {}) {
const spec = TYPES[type];
if (!spec) throw makeError("EBADTYPE", `unknown type: ${type}`);
if (spec.kind === "memory") {
const filePath = memoryPathForScope(scope, opts);
// Memory's containment root is the parent dir (CLAUDE_HOME or project root).
return {
kind: "memoryFile",
target: filePath,
filePath,
containmentRoot: path.dirname(filePath),
};
}
if (spec.kind === "auto-memory") {
const memDir = autoMemoryDir(opts.project);
if (typeof name !== "string" || !MEMORY_FILE_RE.test(name) || name.includes("..")) {
throw makeError("EBADNAME", `auto-memory name must be a flat *.md filename`);
}
const target = path.join(memDir, name);
return { kind: "file", target, filePath: target, containmentRoot: memDir };
}
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw makeError("EBADNAME", `name must match ${NAME_RE}`);
}
const root = rootForScope(scope, opts);
const subdirAbs = path.join(root, spec.subdir);
if (spec.kind === "dir") {
const target = path.join(subdirAbs, name);
return {
kind: "dir",
target,
filePath: path.join(target, spec.filename),
containmentRoot: subdirAbs,
};
}
// file
const target = path.join(subdirAbs, name + spec.ext);
return {
kind: "file",
target,
filePath: target,
containmentRoot: subdirAbs,
};
}
function backupRoot(scope, type, opts = {}) {
return path.join(rootForScope(scope, opts), "cc-config-backups", type);
}
function memoryBackupRoot(scope, opts = {}) {
// Memory's "type" for backup bookkeeping is just "memory"; root sits beside
// the file itself.
const dir = path.dirname(memoryPathForScope(scope, opts));
return path.join(dir, ".cc-config-backups", "memory");
}
function autoMemoryBackupRoot(memDir) {
// Backups live in a dotted subdir of the memory dir. Claude Code only loads
// *.md directly in the dir, so .bak files tucked under a subdir stay inert.
return path.join(memDir, ".cc-config-backups", "auto-memory");
}
function timestamp() {
return new Date().toISOString().replace(/[:]/g, "-");
}
function copyDirSync(src, dst) {
fs.mkdirSync(dst, { recursive: true });
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, ent.name);
const d = path.join(dst, ent.name);
if (ent.isDirectory()) copyDirSync(s, d);
else if (ent.isFile()) fs.copyFileSync(s, d);
// symlinks/sockets/etc skipped intentionally — these surfaces are
// text-file-only by spec
}
}
function rmTreeSync(p) {
fs.rmSync(p, { recursive: true, force: true });
}
/**
* Always-on backup. For files, copies to <backupRoot>/<name>.<ts>.bak. For
* dirs (skills), copies the whole tree. Returns the backup path (or null
* if there was nothing to back up — e.g. brand-new file).
*/
function createBackup({ scope, type, target, kind, opts }) {
if (!fs.existsSync(target)) return null;
let root;
if (type === "memory") root = memoryBackupRoot(scope, opts);
else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target));
else root = backupRoot(scope, type, opts);
fs.mkdirSync(root, { recursive: true });
const base = path.basename(target);
const stamp = timestamp();
if (kind === "dir") {
const dst = path.join(root, `${base}.${stamp}.bak`);
copyDirSync(target, dst);
return dst;
}
// file
const dst = path.join(root, `${base}.${stamp}.bak`);
fs.copyFileSync(target, dst);
return dst;
}
/**
* Atomic write: tmp file → fsync (best-effort) → rename. Tmp is unlinked
* on any failure path. Caller is responsible for ensuring parent dir exists.
*/
function atomicWriteFile(filePath, content) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
let fd;
try {
fd = fs.openSync(tmp, "wx");
fs.writeSync(fd, content);
try {
fs.fsyncSync(fd);
} catch {
// fsync may fail on some filesystems / tmpfs — non-fatal
}
fs.closeSync(fd);
fd = null;
fs.renameSync(tmp, filePath);
} catch (err) {
try {
if (fd != null) fs.closeSync(fd);
} catch {
/* ignore */
}
try {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
} catch {
/* ignore */
}
throw err;
}
}
// ── Public API ─────────────────────────────────────────────────────────
/**
* Create or overwrite a single text artifact. Returns metadata including
* the backup path (null if this was a fresh create).
*
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
*/
function writeArtifact(args) {
const { scope, type, name, content, cwd, project } = args;
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const r = resolveTarget(scope, type, name, { cwd, project });
// Containment guard: even after our regex, double-check that the resolved
// path actually lives under the expected root. Defends against quirks like
// Windows drive letters or normalize-then-resolve mismatches.
if (!isUnder(r.containmentRoot, r.target)) {
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
}
const existedBefore = fs.existsSync(r.filePath);
const backupPath = existedBefore
? createBackup({
scope,
type,
target: r.kind === "dir" ? r.target : r.filePath,
kind: r.kind,
opts: { cwd },
})
: null;
if (r.kind === "dir") {
fs.mkdirSync(r.target, { recursive: true });
}
atomicWriteFile(r.filePath, content);
return {
ok: true,
file: r.filePath,
target: r.target,
backupPath,
created: !existedBefore,
};
}
/**
* Delete a single text artifact. Backup is mandatory and runs first; if
* the backup fails, the original is left intact.
*/
function deleteArtifact(args) {
const { scope, type, name, cwd, project } = args;
const r = resolveTarget(scope, type, name, { cwd, project });
if (!isUnder(r.containmentRoot, r.target)) {
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
}
if (!fs.existsSync(r.target)) {
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
}
const backupPath = createBackup({
scope,
type,
target: r.target,
kind: r.kind === "memoryFile" ? "file" : r.kind,
opts: { cwd },
});
if (r.kind === "dir") {
rmTreeSync(r.target);
} else {
fs.unlinkSync(r.target);
}
return { ok: true, file: r.filePath, target: r.target, backupPath };
}
// ── Keybindings (structured JSON edit) ─────────────────────────────────
//
// keybindings.json is a single user-scope JSON file (~/.claude/keybindings.json).
// Unlike settings.json / ~/.claude.json it is not rewritten mid-session by the
// live CLI, so a backup-before-write edit is safe. We read-modify-write: any
// existing top-level keys ($schema, $docs, and anything we don't model) are
// preserved and only the `bindings` array is replaced, so metadata is never
// dropped. Backups land under CLAUDE_HOME/cc-config-backups/keybindings/.
function keybindingsFile() {
return path.join(getClaudeHome(), "keybindings.json");
}
function keybindingsBackupRoot() {
return path.join(getClaudeHome(), "cc-config-backups", "keybindings");
}
// A keybinding key ("ctrl+t", "escape", "shift+ctrl+f") or action id
// ("toggleTodos"). Bounded, non-empty, single-line printable text.
function validKbString(s, max) {
return typeof s === "string" && s.trim().length >= 1 && s.length <= max && !/[\r\n\t]/.test(s);
}
/**
* Overwrite ~/.claude/keybindings.json from a structured list of groups. Each
* group is `{ context, bindings: [{ key, action }] }`; on disk the bindings
* become an object keyed by `key`. Validates shape, rejects duplicate contexts
* and duplicate keys within a context, backs up the existing file first, then
* writes atomically. Returns `{ ok, file, backupPath, created }`.
*
* @param {{ groups: Array<{context:string, bindings:Array<{key:string,action:string}>}> }} args
*/
function writeKeybindings(args = {}) {
const { groups } = args;
if (!Array.isArray(groups)) {
throw makeError("EBADCONTENT", "groups must be an array");
}
if (groups.length > 200) {
throw makeError("ETOOLARGE", "too many keybinding contexts (max 200)");
}
const outBindings = [];
const seenContexts = new Set();
for (const g of groups) {
if (!g || typeof g !== "object") {
throw makeError("EBADCONTENT", "each group must be an object");
}
const context = typeof g.context === "string" ? g.context.trim() : "";
if (!validKbString(context, 128)) {
throw makeError("EBADCONTENT", "each group needs a non-empty context (<= 128 chars)");
}
if (seenContexts.has(context)) {
throw makeError("EBADCONTENT", `duplicate context: ${context}`);
}
seenContexts.add(context);
const list = Array.isArray(g.bindings) ? g.bindings : [];
if (list.length > 1000) {
throw makeError("ETOOLARGE", `too many bindings in context ${context} (max 1000)`);
}
const map = {};
for (const b of list) {
if (!b || typeof b !== "object") {
throw makeError("EBADCONTENT", `each binding in context ${context} must be an object`);
}
const key = typeof b.key === "string" ? b.key.trim() : "";
const action = typeof b.action === "string" ? b.action.trim() : "";
if (!validKbString(key, 64)) {
throw makeError("EBADCONTENT", `invalid key in context ${context}`);
}
if (!validKbString(action, 128)) {
throw makeError("EBADCONTENT", `invalid action for key "${key}" in context ${context}`);
}
if (Object.prototype.hasOwnProperty.call(map, key)) {
throw makeError("EBADCONTENT", `duplicate key "${key}" in context ${context}`);
}
map[key] = action;
}
outBindings.push({ context, bindings: map });
}
const file = keybindingsFile();
// Preserve any existing top-level metadata ($schema, $docs, unknown keys).
let base = {};
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed;
} catch {
base = {};
}
const nextObj = { ...base, bindings: outBindings };
const content = JSON.stringify(nextObj, null, 2) + "\n";
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const existedBefore = fs.existsSync(file);
let backupPath = null;
if (existedBefore) {
const root = keybindingsBackupRoot();
fs.mkdirSync(root, { recursive: true });
backupPath = path.join(root, `keybindings.json.${timestamp()}.bak`);
fs.copyFileSync(file, backupPath);
}
atomicWriteFile(file, content);
return { ok: true, file, target: file, backupPath, created: !existedBefore };
}
/**
* List backups for either all types or a specific (scope, type) bucket.
* Returns [{ scope, type, name, backupPath, mtime, size }].
*/
function listBackups(opts = {}) {
const out = [];
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
// auto-memory backups live per-project, not under a user/project root — they
// are scanned separately below.
const types = (opts.type ? [opts.type] : Object.keys(TYPES)).filter((t) => t !== "auto-memory");
for (const scope of scopes) {
if (scope === "auto-memory") continue;
for (const type of types) {
const root =
type === "memory" ? memoryBackupRoot(scope, opts) : backupRoot(scope, type, opts);
let entries = [];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
for (const ent of entries) {
const full = path.join(root, ent.name);
let stat;
try {
stat = fs.statSync(full);
} catch {
continue;
}
out.push({
scope,
type,
name: ent.name,
backupPath: full,
isDir: ent.isDirectory(),
mtime: stat.mtimeMs,
size: ent.isDirectory() ? null : stat.size,
});
}
}
}
// Per-project auto-memory backups: ~/.claude/projects/<slug>/memory/
// .cc-config-backups/auto-memory/. Best-effort — never throw.
const wantAuto =
(!opts.type || opts.type === "auto-memory") && (!opts.scope || opts.scope === "auto-memory");
if (wantAuto) {
try {
const projectsRoot = path.join(getClaudeHome(), "projects");
for (const proj of fs.readdirSync(projectsRoot)) {
const root = autoMemoryBackupRoot(path.join(projectsRoot, proj, "memory"));
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
for (const ent of entries) {
const full = path.join(root, ent.name);
let stat;
try {
stat = fs.statSync(full);
} catch {
continue;
}
out.push({
scope: "auto-memory",
project: proj,
type: "auto-memory",
name: ent.name,
backupPath: full,
isDir: ent.isDirectory(),
mtime: stat.mtimeMs,
size: ent.isDirectory() ? null : stat.size,
});
}
}
} catch {
/* ignore */
}
}
return out.sort((a, b) => b.mtime - a.mtime);
}
module.exports = {
writeArtifact,
deleteArtifact,
writeKeybindings,
listBackups,
resolveTarget, // exported for tests
TYPES,
NAME_RE,
};
+212
View File
@@ -0,0 +1,212 @@
/**
* @file cc-watcher.js
* @description Best-effort file watcher for the Claude Code config surfaces
* surfaced by the Config Explorer page. Watches ~/.claude/ recursively (if
* the platform supports it) plus ~/.claude.json and emits a debounced
* `cc_config_changed` over the dashboard websocket so the UI can refetch
* without polling.
*
* Aggressively filters fs.watch events: ~/.claude/ contains lots of churn
* (`projects/*.jsonl` transcripts, `file-history/`, our own
* `cc-config-backups/`) that has nothing to do with the Config Explorer.
* Only paths matching real config surfaces fire a broadcast. Without this
* filter the watcher fires multiple times per second while a claude session
* is active and the page becomes a perpetual loading spinner.
*
* Failures here are non-fatal — `fs.watch` is platform-quirky, and the
* Config Explorer still has a manual Refresh button.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const { getClaudeHome } = require("./claude-home");
const DEBOUNCE_MS = 500;
// Subpaths inside ~/.claude/ that ARE config surfaces and should trigger a
// refetch. Anything else (transcripts, file history, our own backups) is
// ignored. Match is by prefix on the relative path.
const RELEVANT_PREFIXES = [
"settings.json",
"settings.local.json",
"keybindings.json",
"statusline.py",
"statusline-command.sh",
"known_marketplaces.json",
"agents",
"commands",
"skills",
"output-styles",
"hooks",
"plugins",
"CLAUDE.md",
];
// Subpaths to explicitly ignore even if they match RELEVANT_PREFIXES by
// accident. Important: our own backup dir lives at ~/.claude/cc-config-backups/
// and writing backups would re-trigger the watcher in a loop without this.
const IGNORED_PREFIXES = [
"cc-config-backups",
"backups", // Claude Code's own ~/.claude/backups/.claude.json.backup.* churn
"projects",
"file-history",
"todos",
"shell-snapshots",
"ide",
"logs",
"statsig",
];
// Config surfaces that are DIRECTORIES — watched recursively so nested changes
// (e.g. skills/<x>/SKILL.md) still fire. We deliberately watch ONLY these,
// never the whole of ~/.claude/, so the recursive watcher never registers
// interest in high-churn dirs (backups/, projects/, logs/) whose transient
// files crash Node's Linux userland recursive watcher mid-stat.
const WATCH_SUBDIRS = ["agents", "commands", "skills", "output-styles", "hooks", "plugins"];
let started = false;
let timer = null;
let pendingPaths = new Set();
const watchers = [];
function isRelevantUnderHome(home, fullPath) {
const rel = path.relative(home, fullPath);
if (!rel || rel.startsWith("..")) return false;
// First segment of the relative path
const head = rel.split(path.sep)[0];
if (IGNORED_PREFIXES.includes(head)) return false;
if (!RELEVANT_PREFIXES.includes(head)) return false;
return true;
}
function scheduleEmit(broadcast, p) {
if (p) pendingPaths.add(p);
if (timer) return;
timer = setTimeout(() => {
timer = null;
const paths = Array.from(pendingPaths);
pendingPaths = new Set();
if (paths.length === 0) return;
try {
broadcast("cc_config_changed", { source: "fs", paths });
} catch {
/* ignore */
}
}, DEBOUNCE_MS);
}
function safeWatchHome({ home, broadcast }) {
if (!fs.existsSync(home)) return;
// Watch ~/.claude/ itself NON-recursively. Catches top-level config files
// (settings.json, keybindings.json, CLAUDE.md, statusline.*, *.json) plus the
// creation/removal of subdirs. Non-recursive does NOT walk-and-stat children,
// so it never trips the recursive-watcher ENOENT race on churn dirs.
try {
const w = fs.watch(home, (_event, filename) => {
if (!filename) return;
const full = path.join(home, filename);
if (!isRelevantUnderHome(home, full)) return;
scheduleEmit(broadcast, full);
});
w.on("error", () => {});
watchers.push(w);
} catch {
/* platform limitation — best effort only */
}
// Recursively watch ONLY the relevant config subdirs (never backups/, projects/,
// logs/, …) so nested changes still fire without watching the high-churn trees.
for (const sub of WATCH_SUBDIRS) {
const dir = path.join(home, sub);
try {
if (!fs.existsSync(dir)) continue;
const w = fs.watch(dir, { recursive: true }, (_event, filename) => {
const full = filename ? path.join(dir, filename) : dir;
if (!isRelevantUnderHome(home, full)) return;
scheduleEmit(broadcast, full);
});
w.on("error", () => {});
watchers.push(w);
} catch {
/* platform limitation — best effort only */
}
}
}
function safeWatchFile({ target, broadcast }) {
try {
if (!fs.existsSync(target)) return;
const w = fs.watch(target, () => scheduleEmit(broadcast, target));
w.on("error", () => {});
watchers.push(w);
} catch {
/* ignore */
}
}
// Belt-and-suspenders: Node's recursive fs.watch (userland impl on Linux) stats
// changed paths and can throw ENOENT/EPERM when a file vanishes mid-event. That
// throw escapes the watcher's `error` event as an uncaughtException. This watcher
// is explicitly best-effort and must NEVER take down the server, so swallow
// exactly that class of error and let every other uncaught exception crash as
// normal (print + non-zero exit, matching Node's default).
let crashGuard = null;
function installWatchCrashGuard() {
if (crashGuard) return;
crashGuard = (err) => {
const stack = (err && err.stack) || "";
const transientWatch =
err &&
(err.code === "ENOENT" || err.code === "EPERM") &&
err.syscall === "stat" &&
/fs[\\/](recursive_watch|watchers)/.test(stack);
if (transientWatch) return; // vanished file under a watched tree — ignore
// Not ours: preserve default crash behavior.
console.error(err);
process.exit(1);
};
process.on("uncaughtException", crashGuard);
}
function uninstallWatchCrashGuard() {
if (!crashGuard) return;
process.removeListener("uncaughtException", crashGuard);
crashGuard = null;
}
/**
* Start watching the Claude Code config surfaces. Idempotent: subsequent
* calls are no-ops.
*/
function startCcWatcher({ broadcast }) {
if (started) return;
started = true;
installWatchCrashGuard();
const home = getClaudeHome();
safeWatchHome({ home, broadcast });
// ~/.claude.json sits beside ~/.claude/, not inside it.
safeWatchFile({ target: path.join(os.homedir(), ".claude.json"), broadcast });
}
function stopCcWatcher() {
if (timer) {
clearTimeout(timer);
timer = null;
}
for (const w of watchers) {
try {
w.close();
} catch {
/* ignore */
}
}
watchers.length = 0;
pendingPaths = new Set();
uninstallWatchCrashGuard();
started = false;
}
module.exports = { startCcWatcher, stopCcWatcher, isRelevantUnderHome };
+296
View File
@@ -0,0 +1,296 @@
/**
* @file claude-home.js
* @description Centralized Claude Code home directory path management.
* Resolves the projects directory, transcript paths (main + per-subagent),
* and settings file location. Supports a custom root via the CLAUDE_HOME
* environment variable (e.g. ~/.codefuse/engine/cc/) so the dashboard can
* track non-default Claude Code installations.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("path");
const os = require("os");
const fs = require("fs");
function getClaudeHome() {
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
}
function getProjectsDir() {
return path.join(getClaudeHome(), "projects");
}
/**
* Canonical, user-global directory for the dashboard's writable state — the
* SQLite database, VAPID keys, and transcript snapshots. It resolves to the
* SAME absolute path for every launch path (`npm start`, `npm run dev`, and the
* macOS/Windows desktop app), so they all share ONE database instead of each
* host keeping its own. Lives under the Claude home, next to the hook discovery
* file (`~/.claude/.agent-dashboard.json`).
*
* An explicit `DASHBOARD_DATA_DIR` still wins — for tests, power users, or
* anyone pinning a custom location. The earlier default was the repo-local
* `data/` dir, which the desktop app (read-only bundle) couldn't use and which
* never coincided with the web server's copy; see db.js for the one-time
* migration that carries pre-existing databases into this location.
*/
function getDataDir() {
return process.env.DASHBOARD_DATA_DIR || path.join(getClaudeHome(), "agent-dashboard");
}
/**
* Dashboard-owned directory where imported transcripts are snapshotted so the
* Conversation tab survives Claude Code pruning the originals in
* ~/.claude/projects. Lives next to the SQLite DB under the shared data dir.
*/
function getTranscriptSnapshotDir() {
return path.join(getDataDir(), "transcripts");
}
function getSettingsPath() {
return path.join(getClaudeHome(), "settings.json");
}
/**
* Claude Code path encoding: replace all non-alphanumeric characters with "-".
* Example: "/Users/txj/.codefuse" → "-Users-txj--codefuse"
* Note: not just "/", characters like "." are also replaced.
*/
function encodeCwd(cwd) {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}
/**
* Infer the main session JSONL file path from sessionId and cwd.
* Encoding rule: all non-alphanumeric characters replaced with "-".
* Falls back to scanning all project directories if the encoded path doesn't exist.
*/
function getTranscriptPath(sessionId, cwd) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const candidate = path.join(getProjectsDir(), encoded, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
// Fallback: scan projects/ subdirectories
return findTranscriptPath(sessionId);
}
/**
* Resolve a per-agent transcript file inside a session's `subagents` directory,
* supporting BOTH on-disk layouts Claude Code has used for sub-agent transcripts:
* - flat: <subagents>/agent-<agentId>.jsonl
* (regular sub-agents, and older Workflow-tool builds)
* - nested: <subagents>/workflows/<runId>/agent-<agentId>.jsonl
* (current Workflow-tool fan-out runs)
*
* The flat path is checked first, so regular sub-agents resolve exactly as
* before. For the nested layout: when `runId` is known the run directory is read
* directly; when it is unknown the nested tree is scanned and a match is
* returned ONLY if exactly one run contains that agent — an ambiguous agentId
* across multiple runs resolves to null rather than guessing.
*
* @param {string} subagentsDir absolute path to a `.../subagents` directory
* @param {string} agentId the agent-<agentId>.jsonl key (no prefix/suffix)
* @param {string|null} [runId] the workflow run id, when known
* @returns {string|null} absolute transcript path, or null. Never throws.
*/
function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
if (!subagentsDir) return null;
const flat = path.join(subagentsDir, `agent-${agentId}.jsonl`);
if (fs.existsSync(flat)) return flat;
const workflowsDir = path.join(subagentsDir, "workflows");
if (!fs.existsSync(workflowsDir)) return null;
if (runId) {
const nested = path.join(workflowsDir, runId, `agent-${agentId}.jsonl`);
return fs.existsSync(nested) ? nested : null;
}
// Unknown run: accept only an unambiguous single match across all runs.
try {
const matches = [];
for (const d of fs.readdirSync(workflowsDir, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
const cand = path.join(workflowsDir, d.name, `agent-${agentId}.jsonl`);
if (fs.existsSync(cand)) matches.push(cand);
if (matches.length > 1) break;
}
return matches.length === 1 ? matches[0] : null;
} catch {
return null;
}
}
/**
* Infer the sub-agent JSONL file path from sessionId, cwd, agentId, and
* (optionally) the Workflow runId. Resolves both the flat and nested
* Workflow-tool layouts via resolveAgentTranscriptInDir. Falls back to scanning
* all project directories if the encoded path doesn't exist.
*/
function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const subagentsDir = path.join(getProjectsDir(), encoded, sessionId, "subagents");
const direct = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
if (direct) return direct;
// Fallback: scan all project directories
return findSubagentTranscriptPath(sessionId, agentId, runId);
}
/**
* When cwd is unknown, scan projects/ subdirectories to find the JSONL file for a sessionId.
* Returns the found path or null.
*/
function findTranscriptPath(sessionId) {
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
}
} catch {
// Permission or IO error, ignore
}
return null;
}
/**
* Path to the dashboard's durable transcript snapshot for a session, if one
* exists. Snapshots are written at import time (see snapshotTranscript in
* scripts/import-history.js) so the Conversation tab keeps working after Claude
* Code deletes the original under its `cleanupPeriodDays` retention (default
* 30d). Returns the path or null.
*/
function getSnapshotTranscriptPath(sessionId) {
const candidate = path.join(getTranscriptSnapshotDir(), `${sessionId}.jsonl`);
return fs.existsSync(candidate) ? candidate : null;
}
/**
* Path to a snapshotted subagent transcript, mirroring the live layout
* `<snapshotDir>/<sessionId>/subagents/agent-<agentId>.jsonl` (flat) and
* `<snapshotDir>/<sessionId>/subagents/workflows/<runId>/agent-<agentId>.jsonl`
* (nested Workflow-tool runs, preserved by the snapshot writer). Supports the
* same compaction prefix-fuzzy match as findSubagentTranscriptPath. Returns
* the path or null.
*/
function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
const subDir = path.join(getTranscriptSnapshotDir(), sessionId, "subagents");
if (!fs.existsSync(subDir)) return null;
const hit = resolveAgentTranscriptInDir(subDir, agentId, runId);
if (hit) return hit;
if (agentId.startsWith("acompact-")) {
try {
const match = fs
.readdirSync(subDir)
.find((f) => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
if (match) return path.join(subDir, match);
} catch {
/* ignore */
}
}
return null;
}
/**
* Find a sub-agent JSONL file path by scanning when cwd is unknown.
* Supports both layouts (flat + nested Workflow-tool, via
* resolveAgentTranscriptInDir) and a prefix fuzzy match:
* - Exact: agent-<agentId>.jsonl (or workflows/<runId>/agent-<agentId>.jsonl)
* - Fuzzy: agent-acompact-*.jsonl (for compaction type)
*/
function findSubagentTranscriptPath(sessionId, agentId, runId = null) {
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents");
if (!fs.existsSync(subagentsDir)) continue;
// Exact match (flat or nested Workflow-tool layout)
const hit = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
if (hit) return hit;
// Prefix fuzzy match (compaction type: agentId starts with "acompact-")
if (agentId.startsWith("acompact-")) {
const files = fs.readdirSync(subagentsDir);
const match = files.find((f) => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
if (match) return path.join(subagentsDir, match);
}
}
} catch {
// Ignore
}
return null;
}
/**
* Update CLAUDE_HOME at runtime. Updates process.env so getClaudeHome()
* immediately returns the new value, and persists to .env file.
* Returns the resolved absolute path.
*/
function setClaudeHome(newPath) {
const resolved = newPath.replace(/^~(?=\/)/, os.homedir());
if (!path.isAbsolute(resolved)) {
throw new Error("CLAUDE_HOME must be an absolute path");
}
if (!fs.existsSync(resolved)) {
throw new Error(`Directory does not exist: ${resolved}`);
}
const stat = fs.statSync(resolved);
if (!stat.isDirectory()) {
throw new Error(`Not a directory: ${resolved}`);
}
process.env.CLAUDE_HOME = resolved;
writeEnvFile("CLAUDE_HOME", resolved);
return resolved;
}
/**
* Write or update a key=value line in the .env file.
* Creates the file if it doesn't exist.
*/
function writeEnvFile(key, value) {
const envPath = path.resolve(__dirname, "..", "..", ".env");
let lines = [];
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, "utf8").split("\n");
}
let found = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith(`${key}=`)) {
lines[i] = `${key}=${value}`;
found = true;
break;
}
}
if (!found) {
lines.push(`${key}=${value}`);
}
// Write atomically: write to temp file then rename to prevent corruption
const tempPath = envPath + ".tmp";
fs.writeFileSync(tempPath, lines.join("\n") + "\n", "utf8");
fs.renameSync(tempPath, envPath);
}
module.exports = {
getClaudeHome,
getProjectsDir,
getDataDir,
getTranscriptSnapshotDir,
getSettingsPath,
getTranscriptPath,
resolveAgentTranscriptInDir,
getSubagentTranscriptPath,
getSnapshotTranscriptPath,
getSnapshotSubagentTranscriptPath,
findTranscriptPath,
findSubagentTranscriptPath,
setClaudeHome,
writeEnvFile,
};
+160
View File
@@ -0,0 +1,160 @@
/**
* @file dashboard-runs.js
* @description Persistence layer for runs spawned via the dashboard's
* /api/run endpoint. The in-memory handle map in run-spawner.js reaps
* handles 5 min after exit, which is fine for live re-attach but loses
* historical data. This module mirrors every spawn / status transition
* into a sqlite row so the Run page can show a full history of what
* the user has spawned and resume any of those sessions.
*
* All db operations are wrapped in try/catch so a failure here can never
* take down a live run — persistence is a side benefit, not a blocker.
*
* A run started through a lane also carries that lane's id (`lane_id`), so the
* Workspace page can list one lane's own run history; runs spawned straight
* from POST /api/run leave it null.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { db } = require("../db");
const PROMPT_PREVIEW_LIMIT = 500;
const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id
)
`);
const updateStmt = db.prepare(`
UPDATE dashboard_runs
SET session_id = COALESCE(@session_id, session_id),
status = COALESCE(@status, status),
exit_code = COALESCE(@exit_code, exit_code),
ended_at = COALESCE(@ended_at, ended_at)
WHERE id = @id
`);
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code,
started_at, ended_at, lane_id`;
const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs
ORDER BY started_at DESC
LIMIT @limit
`);
const listByLaneStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs
WHERE lane_id = @laneId
ORDER BY started_at DESC
LIMIT @limit
`);
const getStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs WHERE id = @id
`);
/**
* Insert a new run record at spawn time. Idempotent on `id`.
*/
function recordRun(handle) {
try {
const startedAt = new Date(handle.startedAt || Date.now()).toISOString();
const endedAt = handle.endedAt ? new Date(handle.endedAt).toISOString() : null;
const prompt = typeof handle.prompt === "string" ? handle.prompt : "";
insertStmt.run({
id: handle.id,
session_id: handle.sessionId || null,
mode: handle.mode,
cwd: handle.cwd || "",
model: handle.model || null,
permission_mode: handle.permissionMode || null,
effort: handle.effort || null,
resume_session_id: handle.resumeSessionId || null,
prompt_preview: prompt.slice(0, PROMPT_PREVIEW_LIMIT) || null,
status: handle.status || "spawning",
exit_code: typeof handle.exitCode === "number" ? handle.exitCode : null,
started_at: startedAt,
ended_at: endedAt,
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
});
} catch {
/* persistence is best-effort */
}
}
/**
* Patch an existing run record. Pass null/undefined for fields you don't
* want to overwrite — COALESCE in SQL leaves the existing value untouched.
*/
function patchRun({ id, sessionId, status, exitCode, endedAt }) {
try {
updateStmt.run({
id,
session_id: sessionId ?? null,
status: status ?? null,
exit_code: typeof exitCode === "number" ? exitCode : null,
ended_at: endedAt ? new Date(endedAt).toISOString() : null,
});
} catch {
/* ignore */
}
}
/** @param {{limit?: number, laneId?: number|null}} [opts] laneId narrows to one lane's runs. */
function listRuns({ limit = 50, laneId = null } = {}) {
try {
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
if (laneId != null) return listByLaneStmt.all({ limit: safeLimit, laneId });
return listStmt.all({ limit: safeLimit });
} catch {
return [];
}
}
function getRun(id) {
try {
return getStmt.get({ id }) || null;
} catch {
return null;
}
}
const reconcileStmt = db.prepare(`
UPDATE dashboard_runs
SET status = 'abandoned',
ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE status IN ('running', 'spawning')
`);
/**
* On server boot, any rows still flagged `running` or `spawning` are
* orphans — the spawner only persists those statuses for handles it knows
* about, and the in-memory map was just wiped by the restart. Mark them as
* `abandoned` so the UI doesn't display them as live and the user can
* resume them like any other completed past run.
*
* Returns the number of rows updated.
*/
function reconcileOrphans() {
try {
const info = reconcileStmt.run();
return info.changes || 0;
} catch {
return 0;
}
}
module.exports = { recordRun, patchRun, listRuns, getRun, reconcileOrphans };
+255
View File
@@ -0,0 +1,255 @@
/**
* @file server/lib/data-transfer.js
* @description Full-dataset export/import ("backup / restore") for the local
* dashboard database. This is the round-trip counterpart to the transcript
* importer (scripts/import-history.js): where that reconstructs sessions from
* raw Claude Code JSONL, this serializes the dashboard's OWN captured data to a
* single portable JSON bundle and restores it later — the workflow a user needs
* to consolidate several machines into one dashboard.
*
* Design guarantees:
* • Complete — the bundle carries every table that holds user-owned captured
* data or portable configuration: sessions, agents, events, token_usage,
* workflows, dashboard_runs, alert_rules, and model_pricing. Machine-bound
* or secret-bearing tables (push_subscriptions, webhook_targets/deliveries,
* alert_events audit log) are intentionally excluded.
* • Idempotent + non-destructive — restore is session-atomic: a session that
* already exists (matched by its stable UUID) is skipped WHOLE, together
* with its agents/events/token_usage/workflows, so re-importing the same
* bundle (or overlapping bundles from two machines) never duplicates rows
* or clobbers live data. Independent config rows (dashboard_runs,
* alert_rules, model_pricing) are inserted with INSERT OR IGNORE on their
* natural primary key.
* • Accurate — token_usage (including compaction baselines) is restored
* verbatim for every new session, so cost/analytics match the source
* machine exactly. events are re-inserted WITHOUT their source autoincrement
* id (which is not portable across databases); SQLite assigns fresh ids.
* • Schema-tolerant — inserts are built by intersecting each table's live
* columns (PRAGMA table_info) with the keys present on each row, so older
* or newer bundles import cleanly without a migration step.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const EXPORT_FORMAT = "ccam-export";
const EXPORT_VERSION = 1;
// Tables serialized into the bundle. Order matters for restore (parents before
// children); FK checks are deferred to COMMIT anyway (see importExportBundle).
const SESSION_CHILD_TABLES = ["agents", "events", "token_usage", "workflows"];
/**
* Build the full export bundle from the live database.
*
* @param {import("better-sqlite3").Database} db
* @param {{ listPricing: { all: () => any[] } }} stmts - the prepared-statement
* bag from server/db.js (used for the canonical model_pricing ordering).
* @returns {object} A JSON-serializable bundle stamped with format/version.
*/
function buildExportBundle(db, stmts) {
return {
format: EXPORT_FORMAT,
version: EXPORT_VERSION,
exported_at: new Date().toISOString(),
sessions: db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all(),
agents: db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all(),
events: db.prepare("SELECT * FROM events ORDER BY created_at DESC").all(),
token_usage: db.prepare("SELECT * FROM token_usage").all(),
workflows: db.prepare("SELECT * FROM workflows").all(),
dashboard_runs: db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all(),
alert_rules: db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all(),
model_pricing: stmts.listPricing.all(),
};
}
/** Column names of a table, in definition order. */
function tableColumns(db, table) {
return db
.prepare(`PRAGMA table_info(${table})`)
.all()
.map((c) => c.name);
}
// better-sqlite3 only binds numbers/strings/bigints/buffers/null. A row parsed
// from JSON never contains booleans/objects for these tables (SQLite stores
// them as INTEGER/TEXT), but a missing key yields `undefined`, which throws —
// normalize it to null so partial/legacy rows still bind.
function bindable(v) {
if (v === undefined) return null;
if (typeof v === "boolean") return v ? 1 : 0;
return v;
}
/**
* Make a prepared INSERT OR IGNORE that only writes the columns a table
* actually has AND the row actually provides. `omit` drops columns even if
* present (used to strip the non-portable events.id).
*/
function makeInserter(db, table, { omit = [] } = {}) {
const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
const quoted = cols.map((c) => `"${c}"`).join(", ");
const placeholders = cols.map(() => "?").join(", ");
const stmt = db.prepare(`INSERT OR IGNORE INTO ${table} (${quoted}) VALUES (${placeholders})`);
return (row) => stmt.run(cols.map((c) => bindable(row[c])));
}
/** Group an array of rows by a key field into a Map. */
function groupBy(rows, key) {
const map = new Map();
for (const r of Array.isArray(rows) ? rows : []) {
if (!r || r[key] == null) continue;
const k = r[key];
if (!map.has(k)) map.set(k, []);
map.get(k).push(r);
}
return map;
}
class ImportFormatError extends Error {
constructor(message) {
super(message);
this.name = "ImportFormatError";
this.code = "INVALID_FORMAT";
}
}
/**
* Validate a parsed object looks like an export bundle. Accepts bundles stamped
* with our format marker AND legacy exports (pre-versioning) that merely carry
* a `sessions` array, so old backups remain importable.
*
* @throws {ImportFormatError}
*/
function assertBundle(bundle) {
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
throw new ImportFormatError("Not a valid export file (expected a JSON object).");
}
if (bundle.format && bundle.format !== EXPORT_FORMAT) {
throw new ImportFormatError(
`Unrecognized export format "${bundle.format}" (expected "${EXPORT_FORMAT}").`
);
}
const hasAnyTable =
Array.isArray(bundle.sessions) ||
Array.isArray(bundle.model_pricing) ||
Array.isArray(bundle.alert_rules) ||
Array.isArray(bundle.dashboard_runs);
if (!bundle.format && !hasAnyTable) {
throw new ImportFormatError(
"Not a recognizable dashboard export (no sessions/pricing/rules arrays)."
);
}
}
/**
* Restore an export bundle into the live database. Idempotent and
* non-destructive (see file header). Runs inside a single transaction with
* deferred FK checks so agent parent/child ordering never trips a constraint.
*
* @param {import("better-sqlite3").Database} db
* @param {object} bundle - parsed export JSON.
* @returns {{sessions_imported:number, sessions_skipped:number, agents:number,
* events:number, token_usage:number, workflows:number, dashboard_runs:number,
* alert_rules:number, model_pricing:number, errors:number}}
*/
function importExportBundle(db, bundle) {
assertBundle(bundle);
const counters = {
sessions_imported: 0,
sessions_skipped: 0,
agents: 0,
events: 0,
token_usage: 0,
workflows: 0,
dashboard_runs: 0,
alert_rules: 0,
model_pricing: 0,
errors: 0,
};
const sessionExists = db.prepare("SELECT 1 FROM sessions WHERE id = ?").pluck();
const insert = {
sessions: makeInserter(db, "sessions"),
agents: makeInserter(db, "agents"),
events: makeInserter(db, "events", { omit: ["id"] }),
token_usage: makeInserter(db, "token_usage"),
workflows: makeInserter(db, "workflows"),
dashboard_runs: makeInserter(db, "dashboard_runs"),
alert_rules: makeInserter(db, "alert_rules"),
model_pricing: makeInserter(db, "model_pricing"),
};
const childRows = {
agents: groupBy(bundle.agents, "session_id"),
events: groupBy(bundle.events, "session_id"),
token_usage: groupBy(bundle.token_usage, "session_id"),
workflows: groupBy(bundle.workflows, "session_id"),
};
const sessions = Array.isArray(bundle.sessions) ? bundle.sessions : [];
const run = db.transaction(() => {
// Defer FK enforcement to COMMIT: an agent's parent_agent_id may point to a
// sibling that is inserted later in the same batch. Auto-resets at COMMIT.
db.pragma("defer_foreign_keys = ON");
for (const s of sessions) {
if (!s || !s.id) {
counters.errors++;
continue;
}
if (sessionExists.get(s.id)) {
counters.sessions_skipped++;
continue;
}
insert.sessions(s);
counters.sessions_imported++;
// Agents first so events/token_usage that reference them satisfy FKs.
for (const a of childRows.agents.get(s.id) || []) {
if (insert.agents(a).changes > 0) counters.agents++;
}
// Insert events oldest-first so fresh autoincrement ids stay chronological.
const evs = (childRows.events.get(s.id) || [])
.slice()
.sort((a, b) => String(a.created_at || "").localeCompare(String(b.created_at || "")));
for (const e of evs) {
if (insert.events(e).changes > 0) counters.events++;
}
for (const tu of childRows.token_usage.get(s.id) || []) {
if (insert.token_usage(tu).changes > 0) counters.token_usage++;
}
for (const wf of childRows.workflows.get(s.id) || []) {
if (insert.workflows(wf).changes > 0) counters.workflows++;
}
}
// Session-independent, config-like tables: restore by natural PK, never
// overwriting a row the target machine already has.
for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) {
if (r && r.id && insert.dashboard_runs(r).changes > 0) counters.dashboard_runs++;
}
for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) {
if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++;
}
for (const p of Array.isArray(bundle.model_pricing) ? bundle.model_pricing : []) {
if (p && p.model_pattern && insert.model_pricing(p).changes > 0) counters.model_pricing++;
}
});
run();
return counters;
}
module.exports = {
EXPORT_FORMAT,
EXPORT_VERSION,
SESSION_CHILD_TABLES,
buildExportBundle,
importExportBundle,
ImportFormatError,
};
+40
View File
@@ -0,0 +1,40 @@
/**
* @file Per-lane serialization lock. Ensures that only one async operation runs
* on a lane at a time, preventing concurrent git checkout races and other
* worktree collisions. Implemented as a chain of promises keyed by lane ID;
* acquiring a lock waits for the previous holder to settle (success or throw),
* then runs the new work, and releases for the next waiter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const chains = new Map();
/**
* Acquire the per-lane lock, run fn, and release.
* The lock serialises work per lane_id: the next waiter runs only after the
* previous holder settles (success or error). If fn throws, the exception
* propagates to the caller; the chain is *not* poisoned — the next waiter
* still gets a fresh attempt.
*
* @param {number|string} id - Lane ID, converted to string for deduplication.
* @param {() => Promise<T>} fn - Async function to run while holding the lock.
* @returns {Promise<T>} The result of fn, or its thrown error.
*/
function withLaneLock(id, fn) {
const key = String(id);
const prev = chains.get(key) || Promise.resolve();
const run = prev.then(fn, fn); // run regardless of how the previous holder settled
// Keep the chain alive but never let a rejection poison the next waiter.
const settled = run.then(
() => {},
() => {}
);
chains.set(key, settled);
// Clean up the chain entry when it settles to prevent unbounded map growth.
settled.then(() => {
if (chains.get(key) === settled) chains.delete(key);
});
return run;
}
module.exports = { withLaneLock };
+150
View File
@@ -0,0 +1,150 @@
/**
* @file Preflight checks before destructive lane operations (reset, remove, purge).
* Queries the current state without mutations: git status, unpushed commits, sessions
* to be purged, and blockers (adopted lanes, missing directories, unpushed work).
* Every result is read-only; the route and action layer decide what to do with blocks.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { db } = require("../db");
const wt = require("./worktree");
const lanesLib = require("./lanes");
/**
* Preflight for reset, remove, or purge. Returns an object describing what will happen:
* - reset/remove: {action, lane, kind, branch, dirty, untracked, unpushed, head, blocked, warnings}
* - purge: {action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped}
*
* blocked[] holds only conditions that genuinely prevent the action: "adopted" (not
* managed), "missing" (dir gone), "unreadable" (git failed against the directory), and
* "unpushed-commits" (unpushed > 0 — the only one a `force: true` overrides). warnings[]
* holds purely informational facts that never gate the action, starting with "no-remote"
* (no git remote configured — nothing is backed up, but the action proceeds). Both are
* data, not an exception.
*
* @param {object} lane - The lane to check
* @param {string} action - One of "reset", "remove", "purge"
* @returns {Promise<object>} Preflight report
*/
async function preflight(lane, action) {
const blocked = [];
const warnings = [];
// Check if lane is adopted (not managed)
if (lane.kind === "adopted") {
blocked.push("adopted");
}
// For reset/remove, return git status and blockers
if (action === "reset" || action === "remove") {
let dirty = 0;
let untracked = 0;
let unpushed = 0;
let head = null;
// First check if directory exists
if (!fs.existsSync(lane.cwd)) {
blocked.push("missing");
} else {
// Directory exists, try to read git status
try {
// Get status counts
const status = await wt.statusCounts(lane.cwd);
dirty = status.dirty;
untracked = status.untracked;
head = status.head;
// Get unpushed count — measured against the lane's own base branch when
// there is no remote, so it counts this lane's work, not the whole repo.
unpushed = await wt.unpushedCount(lane.cwd, lane.base_branch);
if (unpushed > 0) {
blocked.push("unpushed-commits");
}
// Detect no remotes configured at all — informational only, never a blocker:
// a perfectly ordinary local-only managed lane has no remote at all.
const noRemote = await wt.hasNoRemotes(lane.cwd);
if (noRemote) {
warnings.push("no-remote");
}
} catch (err) {
// Directory exists but git failed (corrupt repo, permission denied, etc.)
blocked.push("unreadable");
}
}
return {
action,
lane: lane.id,
kind: lane.kind,
branch: lane.branch,
dirty,
untracked,
unpushed,
head: head || null,
blocked,
warnings,
};
}
// For purge, count sessions and events to be deleted
if (action === "purge") {
const counts = countPurgeSessions(lane);
return {
action,
lane: lane.id,
sessions: counts.sessions,
events: counts.events,
tokenRows: counts.tokenRows,
bytesEstimate: (counts.events + counts.tokenRows) * 512,
activeSessionSkipped: counts.activeSessionSkipped,
};
}
// Unknown action should never reach here (route validates)
throw Object.assign(new Error(`unknown action: ${action}`), { code: "EBADACTION" });
}
/**
* Count sessions, events, and token_usage rows that would be deleted by purgeLaneSessions.
* Uses the shared purgeCandidateSessions helper to ensure the counts match exactly what
* gets deleted, so the confirmation dialog's numbers are truthful.
*/
function countPurgeSessions(lane) {
const result = { sessions: 0, events: 0, tokenRows: 0, activeSessionSkipped: false };
// Use the shared helpers so the path matching (and its LIKE escaping) has
// exactly one definition, in server/lib/lanes.js.
const sessionsToDelete = lanesLib.purgeCandidateSessions(lane);
result.activeSessionSkipped = lanesLib.hasActiveLaneSession(lane);
if (sessionsToDelete.length === 0) {
return result;
}
// Count events for those sessions
const sessionIds = sessionsToDelete.map((s) => s.id);
const eventsCount = db
.prepare(
`SELECT COUNT(*) as count FROM events WHERE session_id IN (${sessionIds.map(() => "?").join(",")})`
)
.get(...sessionIds);
result.events = eventsCount ? eventsCount.count : 0;
// Count orphaned token_usage rows
const tokenCount = db
.prepare(
`SELECT COUNT(*) as count FROM token_usage WHERE session_id IN (${sessionIds.map(() => "?").join(",")})`
)
.get(...sessionIds);
result.tokenRows = tokenCount ? tokenCount.count : 0;
// Count sessions (for completeness)
result.sessions = sessionsToDelete.length;
return result;
}
module.exports = { preflight };
+514
View File
@@ -0,0 +1,514 @@
/**
* @file Lane storage and lifecycle. A lane is a durable unit of parallel agent
* work — one working directory, many sessions over time — so the dashboard can
* show a pipeline that survives session restarts. This module owns every SQL
* statement touching the `lanes` table, resolves an incoming hook's `cwd` onto a
* lane, records stage transitions (with `stage_since` semantics), and classifies
* liveness the way Shipyard does: a silent watcher is dead, a silent idle lane
* is merely at rest.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { db } = require("../db");
const { getPipeline, phaseIdx, nodeStates, progressPct } = require("./pipelines");
const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300);
/**
* How long a detection holds the forward-only floor.
*
* Five minutes, not thirty: a real session cycles implement -> tests -> ship ->
* implement -> tests within one sitting, and a thirty-minute hold pinned the
* lane at the furthest stage it ever touched — one push left it reading `ship`
* while the agent was demonstrably back to running tests. Five minutes is still
* far longer than a burst of tool calls, so the anti-flap property (a Read right
* after an Edit must not drag the lane back to `plan`) is unaffected.
*
* Read per call, not once at load, so a test and an operator can change it
* without a restart.
*/
function detectionTtlMs() {
const raw = Number(process.env.DETECTION_TTL_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 300_000;
}
/** True when `iso` is absent, unparseable, or older than the TTL. An unknown
* age cannot be proven fresh, so it counts as stale. */
function detectionIsStale(iso) {
if (!iso) return true;
const at = Date.parse(iso);
if (!Number.isFinite(at)) return true;
return Date.now() - at > detectionTtlMs();
}
/** Stages whose whole job is to wait — silence here means the loop died. */
const WATCH_STAGE_RE = /watch|poll/i;
/**
* Fields a client may change through `PATCH /api/lanes/:id`.
*
* `kind`, `source_repo`, `slug` and `base_branch` are deliberately ABSENT: they
* are provisioning-time facts, and `kind` is check 1 of the destroy guard. A
* client that could flip `kind` to "managed" at runtime could point the guard at
* a directory the user owns. Provisioning writes them through
* setProvisioningFacts instead.
*/
const PATCHABLE = new Set([
"title",
"branch",
"pipeline",
"status",
"gate_decision",
"ci_status",
"needs_action",
"links",
"notes",
"session_id",
"run_id",
]);
/** Provisioning-time facts, writable only by this module's internal setter. */
const PROVISIONING_FIELDS = new Set(["kind", "source_repo", "base_branch", "slug"]);
const nowIso = () => new Date().toISOString();
const VALID_KINDS = new Set(["adopted", "managed"]);
function validateKind(kind) {
if (!VALID_KINDS.has(kind)) {
throw Object.assign(new Error(`unknown kind: ${kind}`), { code: "EBADKIND" });
}
}
function hydrate(row) {
if (!row) return null;
let stages = {};
let links = {};
try {
stages = JSON.parse(row.stages || "{}");
} catch {
/* corrupt blob -> empty */
}
try {
links = JSON.parse(row.links || "{}");
} catch {
/* corrupt blob -> empty */
}
return { ...row, stages, links };
}
function createLane({
title = "",
cwd,
branch = null,
pipeline = "default",
kind = "adopted",
source_repo = null,
base_branch = null,
slug = null,
} = {}) {
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
}
validateKind(kind);
const info = db
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run(
title,
cwd.replace(/\/+$/, ""),
branch,
pipeline,
kind,
source_repo,
base_branch,
slug,
nowIso()
);
return getLane(info.lastInsertRowid);
}
function listLanes() {
return db.prepare("SELECT * FROM lanes ORDER BY id ASC").all().map(hydrate);
}
function getLane(id) {
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(id));
}
function updateLane(id, patch = {}) {
// Validate kind before building the UPDATE if it's being set
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
validateKind(patch.kind);
}
const cols = [];
const vals = [];
for (const [k, v] of Object.entries(patch)) {
if (!PATCHABLE.has(k)) continue;
cols.push(`${k} = ?`);
vals.push(k === "links" && typeof v === "object" ? JSON.stringify(v) : v);
}
if (cols.length) {
cols.push("updated_at = ?");
vals.push(nowIso(), id);
db.prepare(`UPDATE lanes SET ${cols.join(", ")} WHERE id = ?`).run(...vals);
}
return getLane(id);
}
/**
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach —
* today only `base_branch`, resolved after `git worktree add` succeeds. Server
* -internal: no route passes user input here.
*
* @param {number} id - The lane id.
* @param {object} facts - Subset of PROVISIONING_FIELDS to write.
*/
function setProvisioningFacts(id, facts = {}) {
const cols = [];
const vals = [];
for (const [k, v] of Object.entries(facts)) {
if (!PROVISIONING_FIELDS.has(k)) continue;
if (k === "kind") validateKind(v);
cols.push(`${k} = ?`);
vals.push(v);
}
if (cols.length) {
cols.push("updated_at = ?");
vals.push(nowIso(), id);
db.prepare(`UPDATE lanes SET ${cols.join(", ")} WHERE id = ?`).run(...vals);
}
return getLane(id);
}
function deleteLane(id) {
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
}
/**
* Record a stage transition. `stage_since` moves ONLY when the stage value
* actually changes, so the UI's time-on-phase is real; a re-report of the same
* stage (a heartbeat, an added note) leaves it alone.
*/
function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage;
const stages = { ...lane.stages };
const prev = stages[next] || {};
stages[next] = {
enteredAt: next === lane.stage && prev.enteredAt ? prev.enteredAt : nowIso(),
evidence: evidence !== undefined ? evidence : prev.evidence || null,
result: result !== undefined ? result : prev.result || null,
};
db.prepare(
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?
WHERE id = ?`
).run(
next,
next === lane.stage ? lane.stage_since || nowIso() : nowIso(),
status || lane.status,
JSON.stringify(stages),
note !== undefined ? note : lane.notes,
nowIso(),
id
);
return getLane(id);
}
/**
* Record an inferred stage from the hook stream. Inference is never evidence
* — this writes only `detected_stage`/`detected_signal`/`detected_at`, never
* `stage` (the declared stage), so a lane's declared meaning never changes.
*
* Writes only when BOTH hold:
* - forward-only: the detection's node index is strictly greater than the
* current `detected_stage`'s index (reading a file after editing it must
* not drag a lane back to `plan`);
* - declared wins: the lane's DECLARED stage index is strictly less than
* the detection's (a lane already declared at `review` ignores an
* `implement` detection).
* Otherwise touches nothing and reports why: `behind-detected`,
* `behind-declared`, or `unknown-node`.
*
* @param {number} id - The lane id.
* @param {{nodeId: string, signal: string}} detection - From stage-detect.detect().
* @returns {{written: boolean, reason?: string}}
*/
function recordDetection(id, { nodeId, signal } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const pipeline = getPipeline(lane.pipeline);
const nodeIdx = phaseIdx(pipeline, nodeId);
if (nodeIdx === -1) return { written: false, reason: "unknown-node" };
// Forward-only holds only while the standing detection is fresh. Once it has
// aged past the TTL the agent has almost certainly moved on to different
// work, so a stale `ship` must not pin the lane forever. Declared-wins below
// is NOT relaxed by staleness - an agent's own claim never expires.
const detectedIdx = detectionIsStale(lane.detected_at)
? -1
: phaseIdx(pipeline, lane.detected_stage);
if (nodeIdx <= detectedIdx) return { written: false, reason: "behind-detected" };
const declaredIdx = phaseIdx(pipeline, lane.stage);
if (declaredIdx >= nodeIdx) return { written: false, reason: "behind-declared" };
db.prepare(
"UPDATE lanes SET detected_stage = ?, detected_signal = ?, detected_at = ? WHERE id = ?"
).run(nodeId, signal || null, nowIso(), id);
return { written: true };
}
/**
* Reset a lane to a blank slate. The detection columns are cleared with the
* declared ones on purpose: a kept `detected_stage` would both paint inferred
* progress for a tree where nothing has happened AND permanently kill detection
* for that lane, because recordDetection is forward-only — a stale `ship` can
* never be advanced past.
*/
function clearLane(id) {
db.prepare(
`UPDATE lanes SET stage = 'idle', stage_since = ?, status = 'idle', gate_decision = NULL,
ci_status = NULL, needs_action = NULL, stages = '{}', notes = NULL, run_id = NULL,
detected_stage = NULL, detected_signal = NULL, detected_at = NULL,
updated_at = ? WHERE id = ?`
).run(nowIso(), nowIso(), id);
return getLane(id);
}
/**
* A provisioning task exists only in the server process that created it. On a
* new boot, any lane still marked provisioning was interrupted before it could
* report a terminal result, so expose it as a removable failure instead.
*
* @returns {number} Number of interrupted lanes recovered.
*/
function recoverInterruptedProvisioning() {
return db
.prepare(
"UPDATE lanes SET status = 'failed', notes = ?, updated_at = ? WHERE status = 'provisioning'"
)
.run("Provisioning was interrupted by a server restart.", nowIso()).changes;
}
/**
* Longest path-boundary prefix match. `/tmp/wt` must NOT capture
* `/tmp/wt-sibling`, and a nested lane must beat its parent.
*/
function resolveLaneByCwd(cwd) {
if (!cwd || typeof cwd !== "string") return null;
const target = cwd.replace(/\/+$/, "");
let best = null;
for (const lane of listLanes()) {
const base = lane.cwd.replace(/\/+$/, "");
if (target === base || target.startsWith(`${base}/`)) {
if (!best || base.length > best.cwd.length) best = lane;
}
}
return best;
}
/** Absolute-looking path tokens a tool's input mentions. Bash carries them
* inside `command` (`cd /path && ...`), editors carry one in `file_path`. */
function absolutePathsIn(toolInput) {
if (!toolInput || typeof toolInput !== "object") return [];
const out = [];
for (const key of ["file_path", "path", "command", "notebook_path"]) {
const value = toolInput[key];
if (typeof value !== "string" || !value) continue;
// Quotes and shell operators are separators, not part of a path.
for (const token of value.split(/[\s'"`;&|()<>]+/)) {
if (token.startsWith("/") && token.length > 1) out.push(token);
}
}
return out;
}
/**
* Which lane should be credited for a tool event.
*
* A hook's `cwd` is the SESSION's directory, not the directory the command
* actually ran in. Measured on a real install: 325 of 400 events carried the
* session's cwd while the edits and test runs happened in another repo reached
* with `cd <other> && ...`, so the lane doing the work detected nothing and the
* lane the terminal started in absorbed all of it.
*
* So prefer a lane named by the tool's own input — the file being edited, the
* directory a command changed into — and fall back to the session's cwd when
* the input names no other lane. Deepest match wins, same as resolveLaneByCwd.
*
* Only stage inference uses this. `session_id` and `needs_action` stay on the
* session's own lane, because those genuinely are session-scoped facts.
*/
function resolveLaneForWork(sessionCwd, toolInput) {
let best = null;
for (const candidate of absolutePathsIn(toolInput)) {
const lane = resolveLaneByCwd(candidate);
if (lane && (!best || lane.cwd.length > best.cwd.length)) best = lane;
}
return best || resolveLaneByCwd(sessionCwd);
}
function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
const expectLive =
status === "running" || status === "provisioning" || WATCH_STAGE_RE.test(stage || "");
if (!expectLive) return "idle";
if (ageSec !== null && ageSec !== undefined && ageSec > deadSec) return "dead";
return "active";
}
/**
* Annotate nodeStates() with `detected: boolean` — true for the detected node
* itself and for any node before it that carries no declaration. Never flips
* a node to `done`: detection only ever adds this flag alongside whatever
* state nodeStates() already computed from the declared stage, which is the
* only path to `done`.
*
* The `current` node is never flagged, even when it has no `stages` entry under
* its own id: declaring by ALIAS (`ccam stage coding` → the `implement` node)
* keys `stages` by the raw declared string, so the node the agent says it is on
* would otherwise render as an inference instead of the blue `current` ring.
*/
function withDetected(states, pipeline, lane) {
const detectedIdx = phaseIdx(pipeline, lane.detected_stage);
if (detectedIdx === -1) return states.map((n) => ({ ...n, detected: false }));
const stages = lane.stages || {};
return states.map((n, i) => ({
...n,
detected: i <= detectedIdx && !stages[n.id] && n.state !== "current",
}));
}
function lanePayload(lane, ageSec = null) {
const pipeline = getPipeline(lane.pipeline);
const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN;
return {
...lane,
pipeline_name: pipeline.name,
pipeline_nodes: withDetected(nodeStates(pipeline, lane), pipeline, lane),
progress: progressPct(pipeline, lane),
stage_seconds: Number.isNaN(since)
? null
: Math.max(0, Math.round((Date.now() - since) / 1000)),
last_event_seconds: ageSec,
liveness: classifyLiveness({ status: lane.status, stage: lane.stage, ageSec }, DEAD_SEC),
};
}
/**
* Build the LIKE pattern matching a lane's subdirectories, escaping the
* characters LIKE treats as wildcards.
*
* CRITICAL: `_` is a single-character wildcard, and every managed lane directory
* is named `<repo>__<slug>` — two literal underscores. Unescaped, a lane at
* `/root/myrepo__feat-foo` also matched `/root/myrepoXXfeat-foo`, so a purge
* deleted a sibling directory's sessions and the preflight count reported the
* victims too: the confirmation was consistently wrong rather than detectably
* wrong. `\` and `%` are escaped for the same reason.
*/
const SUBDIR_LIKE_ESCAPE = "\\";
function subdirLikePattern(cwd) {
return `${cwd.replace(/[\\%_]/g, `${SUBDIR_LIKE_ESCAPE}$&`)}/%`;
}
/**
* Find sessions that belong to a lane and may be purged: exact or subdirectory,
* excluding the lane's bound session and any active sessions. Shared between
* purgeLaneSessions (the deleter) and preflight counting, so the confirmation
* dialog's numbers match what actually gets deleted.
*/
function purgeCandidateSessions(lane) {
return db
.prepare(
`SELECT id FROM sessions
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
AND id != ?
AND status != 'active'`
)
.all(lane.cwd, subdirLikePattern(lane.cwd), lane.session_id || "");
}
/**
* True when a lane owns at least one still-active session, which purge always
* spares. Lives here beside purgeCandidateSessions so both derive their path
* matching from the one escaped helper — preflight used to hand-write this
* clause and inherited the unescaped-`_` bug with it.
*/
function hasActiveLaneSession(lane) {
const row = db
.prepare(
`SELECT COUNT(*) AS count FROM sessions
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
AND id != ?
AND status = 'active'`
)
.get(lane.cwd, subdirLikePattern(lane.cwd), lane.session_id || "");
return Boolean(row && row.count > 0);
}
/**
* Delete all sessions associated with a lane, except the one bound to the lane
* itself (lanes.session_id) and any active sessions. Deletes their events and
* orphaned token_usage rows explicitly (token_usage has no FK to cascade).
* Runs in a single transaction; on completion, runs db.pragma("optimize")
* to update query statistics (never VACUUM, which locks the database).
*
* @param {number} laneId - The lane ID.
* @returns {{sessions: number, events: number, tokenRows: number}} Count of deleted rows.
*/
function purgeLaneSessions(laneId) {
const lane = getLane(laneId);
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
const result = { sessions: 0, events: 0, tokenRows: 0 };
db.transaction(() => {
// Select sessions matching the lane's cwd (exact or subdir), excluding the
// lane's bound session and any active sessions.
const sessionsToDelete = purgeCandidateSessions(lane);
// Delete events for those sessions
result.events = db
.prepare(
`DELETE FROM events WHERE session_id IN (${sessionsToDelete.map(() => "?").join(",")})`
)
.run(...sessionsToDelete.map((s) => s.id)).changes;
// Delete orphaned token_usage rows (token_usage has no FK, so it won't cascade)
result.tokenRows = db
.prepare(
`DELETE FROM token_usage WHERE session_id IN (${sessionsToDelete.map(() => "?").join(",")})`
)
.run(...sessionsToDelete.map((s) => s.id)).changes;
// Delete the sessions themselves
result.sessions = db
.prepare(`DELETE FROM sessions WHERE id IN (${sessionsToDelete.map(() => "?").join(",")})`)
.run(...sessionsToDelete.map((s) => s.id)).changes;
})();
db.pragma("optimize");
return result;
}
module.exports = {
DEAD_SEC,
createLane,
listLanes,
getLane,
updateLane,
deleteLane,
setStage,
recordDetection,
clearLane,
recoverInterruptedProvisioning,
resolveLaneByCwd,
resolveLaneForWork,
classifyLiveness,
lanePayload,
purgeCandidateSessions,
hasActiveLaneSession,
purgeLaneSessions,
setProvisioningFacts,
};
+117
View File
@@ -0,0 +1,117 @@
/**
* @file Pipeline templates for lanes. A template is a plain JSON list of nodes
* (`server/data/pipelines/*.json` plus any override dropped in
* `DASHBOARD_PIPELINES_DIR`); this module resolves a lane's declared stage onto
* a node through per-node `aliases`, and derives the five render states the
* pipeline map draws. Pure functions — no DB, no I/O beyond the one-time
* template load, so it stays trivially testable.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const DEFAULT_PIPELINE_ID = "default";
const BUILTIN_DIR = path.join(__dirname, "..", "data", "pipelines");
/** Load every template once. A malformed file is skipped, never fatal. */
function loadAll() {
const dirs = [BUILTIN_DIR];
if (process.env.DASHBOARD_PIPELINES_DIR) dirs.push(process.env.DASHBOARD_PIPELINES_DIR);
const out = new Map();
for (const dir of dirs) {
let files = [];
try {
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
} catch {
continue; // dir absent — fine
}
for (const f of files) {
try {
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue;
doc.nodes = doc.nodes.map((n) => ({
id: n.id,
label: n.label || n.id,
icon: n.icon || "",
gate: !!n.gate,
aliases: Array.isArray(n.aliases) ? n.aliases : [],
detect: Array.isArray(n.detect) ? n.detect : [],
}));
out.set(doc.id, doc); // later dir wins — user override beats builtin
} catch {
/* skip malformed template */
}
}
}
return out;
}
let cache = null;
function templates() {
if (!cache) cache = loadAll();
return cache;
}
/** Test/dev helper: forget the cached templates so a new file is picked up. */
function reload() {
cache = null;
}
function listPipelines() {
return [...templates().values()];
}
/** Never throws: an unknown id yields the default template. */
function getPipeline(id) {
const t = templates();
return t.get(id) || t.get(DEFAULT_PIPELINE_ID);
}
/** Index of the node matching `stage` by id or alias; -1 when unknown. */
function phaseIdx(pipeline, stage) {
if (!stage) return -1;
const s = String(stage).toLowerCase();
return pipeline.nodes.findIndex(
(n) => n.id.toLowerCase() === s || n.aliases.some((a) => a.toLowerCase() === s)
);
}
/**
* Render state per node:
* failed — the stage recorded result "fail"
* current — the lane's current stage
* done — recorded AND carries evidence (an artifact, not a claim)
* passed-no-evidence — recorded without evidence, or implicitly skipped past
* pending — not reached
*/
function nodeStates(pipeline, lane) {
const stages = lane.stages || {};
const cur = phaseIdx(pipeline, lane.stage);
return pipeline.nodes.map((n, i) => {
const rec = stages[n.id];
let state;
if (rec && rec.result === "fail") state = "failed";
else if (i === cur) state = "current";
else if (rec) state = rec.evidence ? "done" : "passed-no-evidence";
else if (cur > -1 && i < cur) state = "passed-no-evidence";
else state = "pending";
return { id: n.id, label: n.label, icon: n.icon, gate: n.gate, state };
});
}
function progressPct(pipeline, lane) {
const i = phaseIdx(pipeline, lane.stage);
if (i < 0) return 0;
return Math.round((i / (pipeline.nodes.length - 1)) * 100);
}
module.exports = {
DEFAULT_PIPELINE_ID,
listPipelines,
getPipeline,
phaseIdx,
nodeStates,
progressPct,
reload,
};
+57
View File
@@ -0,0 +1,57 @@
/**
* @file Feature-level pricing constants and modifier math, centralized so the
* cost calculator stays readable and every rate has one source of truth. These
* mirror Anthropic's published pricing page. Per-model token rates live in the
* editable `model_pricing` table; the values here are feature/modifier rates
* that are uniform across models and therefore kept as code constants.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// ── Prompt-caching multipliers (relative to base input price) ───────────────
// Stored model rates already encode these for the standard tier, but fast-mode
// cache rates are derived from the fast input base using the same ratios, so we
// keep the multipliers here for that derivation and for documentation.
const CACHE_READ_MULTIPLIER = 0.1; // cache hit / refresh
const CACHE_WRITE_5M_MULTIPLIER = 1.25; // 5-minute ephemeral write
const CACHE_WRITE_1H_MULTIPLIER = 2.0; // 1-hour ephemeral write
// ── Cross-cutting rate modifiers ────────────────────────────────────────────
const DATA_RESIDENCY_US_MULTIPLIER = 1.1; // inference_geo === "us"
const BATCH_DISCOUNT_MULTIPLIER = 0.5; // service_tier === "batch" (50% off)
// ── Server-tool surcharges (billed in addition to tokens) ───────────────────
const WEB_SEARCH_PER_1K_SEARCHES = 10.0; // $10 per 1,000 web_search_requests
const WEB_FETCH_PER_REQUEST = 0.0; // web fetch has no surcharge — tokens only
// Code execution: billed by container-time, not request count. Transcripts only
// expose request counts, so we estimate at the documented 5-minute minimum per
// request. It is FREE when the same request also used web search or web fetch.
// Each org gets a monthly free allowance; below it, code execution costs $0.
const CODE_EXEC_PER_HOUR = 0.05; // $0.05 per container-hour beyond the free tier
const CODE_EXEC_MIN_MINUTES = 5; // 5-minute minimum billed per request
const CODE_EXEC_FREE_HOURS = 1550; // free hours per org per month
/**
* Estimated billable code-execution hours for a bucket.
* Returns 0 when the bucket also used web search or web fetch (code execution is
* free in that case) or when there were no code-execution requests.
*/
function estimateCodeExecHours(codeExecRequests, webSearchRequests, webFetchRequests) {
if (!codeExecRequests || codeExecRequests <= 0) return 0;
if ((webSearchRequests || 0) > 0 || (webFetchRequests || 0) > 0) return 0; // free with search/fetch
return (codeExecRequests * CODE_EXEC_MIN_MINUTES) / 60;
}
module.exports = {
CACHE_READ_MULTIPLIER,
CACHE_WRITE_5M_MULTIPLIER,
CACHE_WRITE_1H_MULTIPLIER,
DATA_RESIDENCY_US_MULTIPLIER,
BATCH_DISCOUNT_MULTIPLIER,
WEB_SEARCH_PER_1K_SEARCHES,
WEB_FETCH_PER_REQUEST,
CODE_EXEC_PER_HOUR,
CODE_EXEC_MIN_MINUTES,
CODE_EXEC_FREE_HOURS,
estimateCodeExecHours,
};
+132
View File
@@ -0,0 +1,132 @@
/**
* @file Handles web push notifications using the `web-push` library, including generating/loading VAPID keys, sending notifications to all subscribed clients, and cleaning up invalid subscriptions. It provides a function to retrieve the public VAPID key for client registration and a function to broadcast notifications to all subscribers stored in the database.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const webpush = require("web-push");
const path = require("path");
const fs = require("fs");
const { getDataDir } = require("./claude-home");
// Lives in the shared data dir alongside the SQLite DB (see getDataDir), so the
// web app and the native apps reuse one set of VAPID keys.
const KEYS_PATH = path.join(getDataDir(), "vapid-keys.json");
function loadOrCreateVapidKeys() {
if (fs.existsSync(KEYS_PATH)) {
return JSON.parse(fs.readFileSync(KEYS_PATH, "utf8"));
}
const keys = webpush.generateVAPIDKeys();
fs.mkdirSync(path.dirname(KEYS_PATH), { recursive: true });
fs.writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2));
return keys;
}
const vapidKeys = loadOrCreateVapidKeys();
webpush.setVapidDetails(
"https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
vapidKeys.publicKey,
vapidKeys.privateKey
);
function getPublicKey() {
return vapidKeys.publicKey;
}
/**
* Fire a native OS notification when this process is the Electron main process
* (i.e. the desktop app embeds the server in-process). Web Push is unreliable
* inside Electron — Chromium-in-Electron ships without Firebase Cloud
* Messaging credentials, so `pushManager.subscribe()` in the renderer either
* fails or returns an endpoint that nothing can ever deliver to, leaving the
* `push_subscriptions` table empty. Calling Electron's main-process
* Notification API directly side-steps the push service entirely.
*
* Returns true when a notification was actually shown.
*
* @param {string} title
* @param {string} body
* @returns {boolean}
*/
function showNativeNotificationIfElectron(title, body) {
if (!process.versions || !process.versions.electron) return false;
try {
// `require("electron")` only resolves inside the Electron runtime; in a
// plain `node server/index.js` host it throws and we fall through.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Notification: ElectronNotification } = require("electron");
if (!ElectronNotification) return false;
if (
typeof ElectronNotification.isSupported === "function" &&
!ElectronNotification.isSupported()
) {
return false;
}
new ElectronNotification({ title, body, silent: false }).show();
return true;
} catch {
return false;
}
}
/**
* Dispatch a notification to every reachable surface:
* - A native Electron notification when hosted inside the desktop app.
* - A Web Push delivery to every subscribed browser endpoint.
*
* Both legs run unconditionally so whichever surface the user is on receives
* the alert. Under `npm start` the native leg is a no-op; under the desktop
* app the Web Push leg is typically a no-op (no FCM credentials in Electron,
* so `push_subscriptions` is empty).
*
* Returns `{ native, pushed, failed }` so the caller can surface what actually
* happened in its API response — silent failures stop looking like success.
*/
async function sendPushToAll(db, title, body) {
const native = showNativeNotificationIfElectron(title, body);
const subscriptions = db.prepare("SELECT * FROM push_subscriptions").all();
if (subscriptions.length === 0) {
return { native, pushed: 0, failed: 0 };
}
const payload = JSON.stringify({
title,
body,
icon: "https://raw.githubusercontent.com/Smartgift-AI/Claude-Code-Monitor/main/client/public/favicon.ico",
badge:
"https://raw.githubusercontent.com/Smartgift-AI/Claude-Code-Monitor/main/client/public/favicon.ico",
silent: false,
sound: "default",
});
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload
)
)
);
// Remove subscriptions that are gone (HTTP 410); count what landed.
let pushed = 0;
let failed = 0;
for (let index = 0; index < results.length; index++) {
const result = results[index];
if (result.status === "fulfilled") {
pushed++;
} else {
failed++;
if (result.reason?.statusCode === 410) {
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(
subscriptions[index].endpoint
);
}
}
}
return { native, pushed, failed };
}
module.exports = { getPublicKey, sendPushToAll, showNativeNotificationIfElectron };
+53
View File
@@ -0,0 +1,53 @@
/**
* @file Self-hosted ReDoc API reference. ReDoc renders the OpenAPI spec as a
* clean, three-panel reference document — a read-optimized complement to
* Swagger UI's interactive "try it out" console (both are served from the same
* `/api/openapi.json` spec). The ReDoc bundle ships with the `redoc`
* dependency and is served straight from `node_modules` rather than a CDN, so
* the docs render fully offline / air-gapped, consistent with the project's
* no-external-assets policy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/**
* Absolute path to the prebuilt ReDoc standalone bundle inside the installed
* `redoc` package. Resolved through Node's module resolution so it works
* regardless of hoisting / install layout. Throws if `redoc` is not installed.
* @returns {string}
*/
function redocBundlePath() {
return require.resolve("redoc/bundles/redoc.standalone.js");
}
/**
* Minimal HTML shell that boots ReDoc against a spec URL using the
* locally-served bundle. Makes no external network requests.
*
* @param {string} specUrl URL the browser fetches the OpenAPI JSON from.
* @param {string} bundleUrl URL the page loads the ReDoc bundle from.
* @param {string} title Document <title> and browser-tab label.
* @returns {string} A complete HTML document.
*/
function renderRedocHtml(specUrl, bundleUrl, title) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url="${specUrl}"></redoc>
<script src="${bundleUrl}"></script>
</body>
</html>
`;
}
module.exports = { redocBundlePath, renderRedocHtml };
Binary file not shown.
+567
View File
@@ -0,0 +1,567 @@
/**
* @file run-spawner.js
* @description Spawns and supervises Claude Code subprocesses for the
* dashboard's Run page. Two modes:
* - "headless" — single-shot. Stdin is closed after spawn; the prompt
* lives in argv via `-p`. Process exits when the model
* finishes the turn.
* - "conversation" — multi-turn. Stdin stays open; follow-up turns are
* delivered via JSON envelopes through stdin and the
* caller can pipe more messages until they kill or the
* child exits naturally.
*
* Conversation mode also supports resuming an existing session via
* `--resume <session-id>`, so the user can continue any prior Claude Code
* conversation from inside the dashboard.
*
* Output is always `--output-format stream-json --verbose` so the parser can
* deliver structured envelopes (system/init, assistant text+tool_use, user
* tool_result, result/success, etc). Each envelope is broadcast over the
* dashboard's existing WebSocket as a `run_stream` message; status changes
* (spawning → running → completed/error/killed) broadcast as `run_status`.
*
* Concurrency is capped (RUN_MAX_CONCURRENT, default 10) — over the cap we
* throw ECONCURRENCY with the running set so the route can return 429.
*
* When a child truly finishes (real exit, or a spawn that never started) the
* handler registered via setRunExitHandler is called once. That inversion is
* how a lane gets released without this module requiring the lane router back.
*
* Each handle keeps a bounded in-memory envelope log (cap 500) so a client
* that attaches late can replay what it missed. Completed handles are reaped
* after 5 min — but the underlying transcripts persist via the normal hook
* ingestion pipeline (every spawned `claude` fires hooks like any other
* session, so the run shows up in /sessions automatically).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// cross-spawn (not node:child_process): on Windows the npm-installed `claude`
// is a `.cmd` shim that plain spawn can't launch, and the naive fix (`shell:
// true`) would run argv — including the user-controlled prompt/model — through
// cmd.exe, opening a command-injection hole. cross-spawn resolves the shim and
// escapes arguments safely without a shell. On macOS/Linux it is a plain spawn.
const spawn = require("cross-spawn");
const { randomUUID } = require("node:crypto");
const { broadcast } = require("../websocket");
const { createLineParser } = require("./stream-json-parser");
// Persistence is best-effort and optional — load lazily so unit tests that
// don't bring up the full db can still exercise the spawner.
let dashboardRuns = null;
try {
dashboardRuns = require("./dashboard-runs");
} catch {
/* db-less environment, skip persistence */
}
function recordRun(handle) {
if (dashboardRuns) dashboardRuns.recordRun(handle);
}
function patchRun(args) {
if (dashboardRuns) dashboardRuns.patchRun(args);
}
// Whoever owns lanes registers here at boot (routes/lanes.js) so a finished run
// can release its lane. The dependency is inverted deliberately: the lane router
// already requires THIS module, and releasing needs the router's lanePayload /
// lastEventAge to broadcast — requiring it back would be a cycle.
let runExitHandler = null;
function setRunExitHandler(fn) {
runExitHandler = typeof fn === "function" ? fn : null;
}
/** Announce a truly-exited run. Never lets a listener break run bookkeeping. */
function notifyRunExit(handle) {
if (!runExitHandler) return;
try {
runExitHandler({ runId: handle.id, laneId: handle.laneId || null });
} catch {
/* a broken listener is not the run's problem */
}
}
// Effectively uncapped — claude's terminal TUI doesn't gate concurrent
// sessions, so we don't either. The number is high enough that a buggy
// client still can't fork-bomb the host before someone notices, but low
// enough that no human will ever hit it organically. Users who want a
// real cap can set RUN_MAX_CONCURRENT.
const MAX_CONCURRENT_DEFAULT = 10000;
const REAP_AFTER_MS = 5 * 60 * 1000; // keep handle for 5 min after exit
const STDOUT_TAIL_BYTES = 4 * 1024;
const STDERR_TAIL_BYTES = 4 * 1024;
// Cap stored envelopes per handle so a long-running conversation doesn't
// balloon memory. Late-attaching clients get this much history; the full
// transcript is always available via the existing /sessions/<id> view.
const MAX_ENVELOPES_PER_HANDLE = 500;
const handles = new Map();
const reapers = new Map();
function getMaxConcurrent() {
const raw = process.env.RUN_MAX_CONCURRENT;
if (!raw) return MAX_CONCURRENT_DEFAULT;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : MAX_CONCURRENT_DEFAULT;
}
function liveCount() {
let n = 0;
for (const h of handles.values()) {
if (h.status === "spawning" || h.status === "running") n++;
}
return n;
}
function tail(s, n) {
if (typeof s !== "string") return "";
if (s.length <= n) return s;
return s.slice(s.length - n);
}
/**
* Build argv for the `claude` invocation. The two modes have different argv
* shapes because of how Claude Code resolves the first user message:
*
* - HEADLESS: `-p "<prompt>"` carries the prompt; stdin is closed; Claude
* processes one turn and exits.
* - CONVERSATION: `--input-format stream-json` puts Claude in multi-turn
* mode where ALL user turns (including the first) come via stdin. When
* stream-json input is enabled, `-p` is silently ignored — so we OMIT
* it and send the initial prompt over stdin in `spawnRun` immediately
* after the spawn handshake.
*/
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
function buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort }) {
const argv = [];
argv.push("--output-format", "stream-json");
argv.push("--verbose");
// Real character-by-character streaming. Without this flag Claude only
// emits the *final* assistant envelope, which makes the UI feel like the
// response arrives all at once. With it, we also receive `stream_event`
// envelopes (Anthropic Messages API streaming events) so the UI can
// render text + thinking deltas as they arrive.
argv.push("--include-partial-messages");
argv.push("--permission-mode", permissionMode || "acceptEdits");
if (mode === "headless") {
argv.push("-p", prompt);
} else {
argv.push("--input-format", "stream-json");
}
if (model) {
argv.push("--model", model);
}
if (effort && EFFORT_LEVELS.has(effort)) {
// Drives thinking depth: higher = more reasoning tokens before the
// assistant turn. Empty / unset means "inherit from the model's default".
argv.push("--effort", effort);
}
if (resumeSessionId) {
argv.push("--resume", resumeSessionId);
}
return argv;
}
/**
* Frame a stream-json user envelope. Used both for the initial conversation-
* mode prompt and for follow-up turns via sendInput.
*/
function userEnvelope(text, id) {
const e = {
type: "user",
message: { role: "user", content: text },
};
if (id) e.id = id;
return JSON.stringify(e) + "\n";
}
/**
* Strip dashboard-internal env vars from the child so the spawned `claude`
* doesn't accidentally pick up our hook-handler context (and to keep the
* child's auth entirely from the user's existing OAuth in $HOME).
*/
function cleanSpawnEnv() {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST;
return env;
}
function attachStreamHandlers(handle) {
const parser = createLineParser(
(envelope) => {
// First parsed envelope means the child is producing output → "running".
if (handle.status === "spawning") {
handle.status = "running";
broadcast("run_status", { id: handle.id, status: "running", at: Date.now() });
patchRun({ id: handle.id, status: "running" });
}
// Capture session_id off the system/init envelope — once we have it the
// dashboard can deep-link to /sessions/<id> on completion.
if (
envelope &&
envelope.type === "system" &&
envelope.subtype === "init" &&
typeof envelope.session_id === "string"
) {
const wasNull = !handle.sessionId;
handle.sessionId = envelope.session_id;
if (wasNull) patchRun({ id: handle.id, sessionId: envelope.session_id });
}
handle.envelopeCount += 1;
handle.envelopes.push(envelope);
// Keep only the most recent N — older entries are still in the disk
// transcript at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl,
// visible via the regular /sessions/<id> dashboard view.
if (handle.envelopes.length > MAX_ENVELOPES_PER_HANDLE) {
handle.envelopes.splice(0, handle.envelopes.length - MAX_ENVELOPES_PER_HANDLE);
}
broadcast("run_stream", { id: handle.id, envelope });
},
(err, raw) => {
handle.stderrBuffer += `[parse-error] ${err.message}: ${raw}\n`;
}
);
handle.child.stdout.on("data", (chunk) => {
const s = chunk.toString("utf8");
handle.stdoutBuffer = tail(handle.stdoutBuffer + s, STDOUT_TAIL_BYTES);
parser.push(s);
});
handle.child.stderr.on("data", (chunk) => {
handle.stderrBuffer = tail(handle.stderrBuffer + chunk.toString("utf8"), STDERR_TAIL_BYTES);
});
handle.child.on("error", (err) => {
// A spawn error has no corresponding `exit` event: the OS never started
// the child, so it can no longer touch the lane directory.
handle.actualExitedAt = Date.now();
handle.status = "error";
handle.error = err.message;
handle.endedAt = Date.now();
broadcast("run_status", {
id: handle.id,
status: "error",
error: err.message,
at: handle.endedAt,
});
patchRun({ id: handle.id, status: "error", endedAt: handle.endedAt });
scheduleReap(handle.id);
// A spawn that never started is just as finished as one that ran: without
// this the lane stays `running` forever with a dead run_id.
notifyRunExit(handle);
});
handle.child.on("exit", (code, signal) => {
parser.flush();
// `killRun` deliberately sets status to `killed` immediately after it
// requests SIGTERM. Keep this separate, exit-only signal so callers that
// must not touch a run's cwd until the OS reaps it can wait truthfully.
handle.actualExitedAt = Date.now();
if (handle.status === "killed") {
// already broadcast — patchRun already happened in stop()
} else {
handle.status = code === 0 ? "completed" : "error";
handle.exitCode = code;
handle.signal = signal;
handle.endedAt = Date.now();
broadcast("run_status", {
id: handle.id,
status: handle.status,
exitCode: code,
sessionId: handle.sessionId || null,
at: handle.endedAt,
});
patchRun({
id: handle.id,
status: handle.status,
exitCode: code,
sessionId: handle.sessionId || null,
endedAt: handle.endedAt,
});
}
scheduleReap(handle.id);
// Fires for a killed run too — killRun only flags `killed` before the OS
// reaps the child; a killed run is a finished run.
notifyRunExit(handle);
});
}
function scheduleReap(id) {
const existing = reapers.get(id);
if (existing) clearTimeout(existing);
const t = setTimeout(() => {
handles.delete(id);
reapers.delete(id);
}, REAP_AFTER_MS);
// Don't keep the process alive just for the reap timer.
if (typeof t.unref === "function") t.unref();
reapers.set(id, t);
}
/**
* @param {object} args
* @param {string} args.prompt
* @param {"headless"|"conversation"} args.mode
* @param {string} [args.cwd]
* @param {string} [args.model]
* @param {string} [args.permissionMode]
* @param {number} [args.laneId] Lane this run was started through; persisted so
* the Workspace page can list one lane's runs. Omitted by POST /api/run.
* @returns handle
*/
function spawnRun(args) {
const { prompt, mode, cwd, model, permissionMode, resumeSessionId, effort, laneId } = args || {};
if (typeof prompt !== "string") {
throw makeErr("EBADPROMPT", "prompt is required");
}
// Empty prompt is allowed only when resuming a conversation — claude
// idles on the resumed transcript until the user types a follow-up.
if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) {
throw makeErr("EBADPROMPT", "prompt is required");
}
if (mode !== "headless" && mode !== "conversation") {
throw makeErr("EBADMODE", `mode must be "headless" or "conversation"`);
}
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
}
if (resumeSessionId != null) {
if (typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId)) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
// Resume only makes sense in conversation mode (you want to keep talking).
// Headless `claude --resume` does run, but the UX of "send one prompt and
// exit" on a resumed session is confusing — disallow.
if (mode !== "conversation") {
throw makeErr("EBADMODE", "resumeSessionId requires conversation mode");
}
}
const max = getMaxConcurrent();
if (liveCount() >= max) {
const err = makeErr("ECONCURRENCY", `concurrency limit ${max} reached`);
err.running = Array.from(handles.values())
.filter((h) => h.status === "running" || h.status === "spawning")
.map((h) => ({ id: h.id, pid: h.pid, startedAt: h.startedAt, mode: h.mode }));
throw err;
}
const id = randomUUID();
const argv = buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort });
// cross-spawn handles the Windows `.cmd` shim safely (see the require above);
// deliberately no `shell` option, so argv is never parsed by cmd.exe.
const child = spawn("claude", argv, {
env: cleanSpawnEnv(),
cwd: cwd || process.cwd(),
stdio: ["pipe", "pipe", "pipe"],
});
const handle = {
id,
pid: child.pid || null,
mode,
cwd: cwd || process.cwd(),
model: model || null,
permissionMode: permissionMode || "acceptEdits",
effort: effort || null,
prompt,
argv,
resumeSessionId: resumeSessionId || null,
laneId: typeof laneId === "number" ? laneId : null,
status: "spawning",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
actualExitedAt: null,
sessionId: resumeSessionId || null, // optimistic; will be confirmed by system/init envelope
envelopeCount: 0,
envelopes: [],
stdoutBuffer: "",
stderrBuffer: "",
child,
};
handles.set(id, handle);
recordRun(handle);
attachStreamHandlers(handle);
if (mode === "headless") {
// Headless: prompt is in argv; close stdin so Claude knows nothing more
// is coming and exits after the one turn.
try {
child.stdin.end();
} catch {
/* ignore */
}
} else if (prompt && prompt.trim()) {
// Conversation: deliver the initial prompt over stdin so Claude in
// stream-json input mode actually starts processing it. Stdin stays
// open for follow-up turns.
try {
child.stdin.write(userEnvelope(prompt));
} catch (err) {
handle.stderrBuffer += `[stdin-write-error] ${err.message}\n`;
}
}
// Conversation with empty prompt (resume scenarios) — leave stdin open;
// claude will idle on the resumed conversation until the user types a
// follow-up via POST /:id/message.
broadcast("run_status", { id, status: "spawning", at: handle.startedAt });
return handle;
}
/**
* Send a follow-up user turn into a running conversation. Throws if the
* handle is not running, not in conversation mode, or stdin is closed.
*/
function sendInput(id, text) {
const handle = handles.get(id);
if (!handle) throw makeErr("ENOTFOUND", "run not found");
if (handle.mode !== "conversation") {
throw makeErr("EWRONGMODE", "only conversation mode accepts follow-up input");
}
if (handle.status !== "running" && handle.status !== "spawning") {
throw makeErr("ENOTRUNNING", `run is ${handle.status}`);
}
if (typeof text !== "string" || !text) {
throw makeErr("EBADINPUT", "text is required");
}
if (!handle.child || !handle.child.stdin || !handle.child.stdin.writable) {
throw makeErr("ESTDINCLOSED", "stdin is not writable");
}
const messageId = randomUUID();
handle.child.stdin.write(userEnvelope(text, messageId));
broadcast("run_input_ack", { id, messageId, at: Date.now() });
return { messageId };
}
function killRun(id) {
const handle = handles.get(id);
if (!handle) return false;
if (handle.status === "completed" || handle.status === "error" || handle.status === "killed") {
return true;
}
if (handle.child && !handle.child.killed) {
try {
handle.child.kill("SIGTERM");
} catch {
/* ignore */
}
setTimeout(() => {
const h = handles.get(id);
if (h && h.child && !h.actualExitedAt) {
try {
h.child.kill("SIGKILL");
} catch {
/* ignore */
}
}
}, 5000).unref?.();
}
handle.status = "killed";
handle.endedAt = Date.now();
broadcast("run_status", { id, status: "killed", at: handle.endedAt });
patchRun({ id, status: "killed", endedAt: handle.endedAt });
scheduleReap(id);
return true;
}
function publicHandle(handle, opts = {}) {
if (!handle) return null;
const out = {
id: handle.id,
pid: handle.pid,
mode: handle.mode,
cwd: handle.cwd,
model: handle.model,
permissionMode: handle.permissionMode,
effort: handle.effort || null,
prompt: handle.prompt,
argv: handle.argv,
resumeSessionId: handle.resumeSessionId || null,
status: handle.status,
startedAt: handle.startedAt,
endedAt: handle.endedAt,
exitCode: handle.exitCode,
signal: handle.signal,
error: handle.error,
actualExitedAt: handle.actualExitedAt,
sessionId: handle.sessionId,
envelopeCount: handle.envelopeCount,
stdoutTail: handle.stdoutBuffer,
stderrTail: handle.stderrBuffer,
};
if (opts.includeEnvelopes) {
out.envelopes = handle.envelopes.slice();
}
return out;
}
function getRun(id, opts = {}) {
return publicHandle(handles.get(id), opts);
}
function listRuns() {
return Array.from(handles.values())
.sort((a, b) => b.startedAt - a.startedAt)
.map(publicHandle);
}
function makeErr(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
// Test seam: inject a fake child (e.g. PassThrough streams) without invoking
// the real `claude` binary. Returns the handle.
function __injectChildForTest({ child, mode = "conversation", prompt = "test" }) {
const id = randomUUID();
const handle = {
id,
pid: 0,
mode,
cwd: process.cwd(),
model: null,
permissionMode: "acceptEdits",
effort: null,
prompt,
argv: ["-p", prompt],
resumeSessionId: null,
status: "spawning",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
actualExitedAt: null,
sessionId: null,
envelopeCount: 0,
envelopes: [],
stdoutBuffer: "",
stderrBuffer: "",
child,
};
handles.set(id, handle);
attachStreamHandlers(handle);
return handle;
}
function __reset() {
for (const t of reapers.values()) clearTimeout(t);
reapers.clear();
handles.clear();
}
module.exports = {
spawnRun,
setRunExitHandler,
sendInput,
killRun,
getRun,
listRuns,
liveCount,
getMaxConcurrent,
__injectChildForTest,
__reset,
};
+191
View File
@@ -0,0 +1,191 @@
/**
* @file scoped-stats.js
* @description Source-scoped variants of the dashboard's aggregate queries
* (stats + analytics). When the user restricts the "data scope" to a subset of
* machines (see server/lib/source-filter.js), the routes call these instead of
* the cached prepared statements in db.js so EVERY headline number — session /
* agent / event counts, token totals, cost, daily charts, tool + type
* distributions — reflects only the chosen sources.
*
* These build SQL dynamically (per request) and are used ONLY on the filtered
* path; the unfiltered default keeps using db.js's prepared statements, so the
* common zero-config case pays nothing for this feature.
*
* Every function takes a non-empty `sources` string array. The predicate is
* either `source IN (...)` (queries over `sessions`) or a `session_id IN
* (SELECT id FROM sessions WHERE source IN (...))` subquery (queries over
* events / agents / token_usage), always via bound parameters.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** `?,?,…` for N sources. */
function ph(sources) {
return sources.map(() => "?").join(",");
}
/** Subquery restricting a `session_id` column to the chosen sources. */
function sessionSubquery(sources) {
return `SELECT id FROM sessions WHERE source IN (${ph(sources)})`;
}
function statsOverview(db, sources) {
const sq = sessionSubquery(sources);
const p = ph(sources);
const row = db
.prepare(
`SELECT
(SELECT COUNT(*) FROM sessions WHERE source IN (${p})) as total_sessions,
(SELECT COUNT(*) FROM sessions WHERE status = 'active' AND source IN (${p})) as active_sessions,
(SELECT COUNT(*) FROM agents WHERE status IN ('working','waiting') AND session_id IN (${sq})) as active_agents,
(SELECT COUNT(*) FROM agents WHERE session_id IN (${sq})) as total_agents,
(SELECT COUNT(*) FROM events WHERE session_id IN (${sq})) as total_events`
)
.get(...sources, ...sources, ...sources, ...sources, ...sources);
return row;
}
function agentStatusCounts(db, sources) {
return db
.prepare(
`SELECT status, COUNT(*) as count FROM agents WHERE session_id IN (${sessionSubquery(
sources
)}) GROUP BY status`
)
.all(...sources);
}
function sessionStatusCounts(db, sources) {
return db
.prepare(
`SELECT status, COUNT(*) as count FROM sessions WHERE source IN (${ph(sources)}) GROUP BY status`
)
.all(...sources);
}
function countEventsToday(db, sources, toLocal, toUTC) {
return db
.prepare(
`SELECT COUNT(*) as count FROM events
WHERE created_at >= datetime('now', ?, 'start of day', ?)
AND session_id IN (${sessionSubquery(sources)})`
)
.get(toLocal, toUTC, ...sources);
}
function tokenTotals(db, sources) {
return db
.prepare(
`SELECT
COALESCE(SUM(input_tokens + baseline_input), 0) as total_input,
COALESCE(SUM(output_tokens + baseline_output), 0) as total_output,
COALESCE(SUM(cache_read_tokens + baseline_cache_read), 0) as total_cache_read,
COALESCE(SUM(cache_write_tokens + baseline_cache_write), 0) as total_cache_write,
COALESCE(SUM(cache_write_1h_tokens + baseline_cache_write_1h), 0) as total_cache_write_1h,
COALESCE(SUM(web_search_requests + baseline_web_search), 0) as total_web_search,
COALESCE(SUM(web_fetch_requests + baseline_web_fetch), 0) as total_web_fetch,
COALESCE(SUM(code_execution_requests + baseline_code_execution), 0) as total_code_execution
FROM token_usage WHERE session_id IN (${sessionSubquery(sources)})`
)
.get(...sources);
}
function toolUsageCounts(db, sources) {
return db
.prepare(
`SELECT tool_name, COUNT(*) as count FROM events
WHERE tool_name IS NOT NULL AND session_id IN (${sessionSubquery(sources)})
GROUP BY tool_name ORDER BY count DESC LIMIT 20`
)
.all(...sources);
}
function dailyEventCounts(db, sources, tzModifier) {
return db
.prepare(
`SELECT DATE(created_at, ?) as date, COUNT(*) as count FROM events
WHERE created_at >= DATE('now', '-365 days') AND session_id IN (${sessionSubquery(sources)})
GROUP BY 1 ORDER BY date ASC`
)
.all(tzModifier, ...sources);
}
function dailySessionCounts(db, sources, tzModifier) {
return db
.prepare(
`SELECT DATE(started_at, ?) as date, COUNT(*) as count FROM sessions
WHERE started_at >= DATE('now', '-365 days') AND source IN (${ph(sources)})
GROUP BY 1 ORDER BY date ASC`
)
.all(tzModifier, ...sources);
}
function agentTypeDistribution(db, sources) {
return db
.prepare(
`SELECT subagent_type, COUNT(*) as count FROM agents
WHERE type = 'subagent' AND subagent_type IS NOT NULL AND session_id IN (${sessionSubquery(
sources
)})
GROUP BY subagent_type ORDER BY count DESC`
)
.all(...sources);
}
function totalSubagentCount(db, sources) {
return db
.prepare(
`SELECT COUNT(*) as count FROM agents WHERE type = 'subagent' AND session_id IN (${sessionSubquery(
sources
)})`
)
.get(...sources);
}
function eventTypeCounts(db, sources) {
return db
.prepare(
`SELECT event_type, COUNT(*) as count FROM events
WHERE session_id IN (${sessionSubquery(sources)})
GROUP BY event_type ORDER BY count DESC`
)
.all(...sources);
}
function avgEventsPerSession(db, sources) {
const sq = sessionSubquery(sources);
return db
.prepare(
`SELECT ROUND(CAST(COUNT(*) AS REAL) /
MAX(1, (SELECT COUNT(*) FROM sessions WHERE source IN (${ph(sources)}))), 1) as avg
FROM events WHERE session_id IN (${sq})`
)
.get(...sources, ...sources);
}
/** token_usage rows joined to their session start date, scoped to sources. */
function scopedTokenUsageWithDate(db, sources) {
return db
.prepare(
`SELECT tu.*, DATE(s.started_at) as date
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
WHERE s.source IN (${ph(sources)})`
)
.all(...sources);
}
module.exports = {
statsOverview,
agentStatusCounts,
sessionStatusCounts,
countEventsToday,
tokenTotals,
toolUsageCounts,
dailyEventCounts,
dailySessionCounts,
agentTypeDistribution,
totalSubagentCount,
eventTypeCounts,
avgEventsPerSession,
scopedTokenUsageWithDate,
};
+177
View File
@@ -0,0 +1,177 @@
/**
* @file security.js
* @description Network-exposure hardening for the dashboard server
* (GHSA-gr74-4xfh-6jw9). The server historically bound 0.0.0.0 with no auth and
* `cors()` (Access-Control-Allow-Origin: *), exposing transcripts, data export,
* local-directory reads, ~/.claude writes, and a claude-spawning endpoint to any
* host on the network. This module centralizes the defenses:
*
* 1. Default bind to loopback (127.0.0.1); opt into a wider bind only via the
* explicit DASHBOARD_HOST env (with a startup warning).
* 2. Host-header allowlist — rejects requests whose Host isn't loopback (or an
* operator-allowlisted name), which defeats DNS-rebinding drive-bys.
* 3. CORS restricted to loopback origins (no more `*`).
* 4. An OPTIONAL bearer token (DASHBOARD_TOKEN) gating /api/* and the
* WebSocket — for operators who deliberately bind to a LAN. Off by default
* so the zero-config loopback experience is unchanged.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
// Hostnames that count as "this machine". "0.0.0.0" is included because a
// browser may resolve a 0.0.0.0 bind via localhost; an empty Host is treated as
// loopback (HTTP/1.0 / local tooling).
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", ""]);
/** The interface to bind. Loopback unless the operator opts into a wider bind. */
function resolveHost() {
const h = (process.env.DASHBOARD_HOST || "").trim();
return h || "127.0.0.1";
}
function isLoopbackHostname(name) {
return LOOPBACK_HOSTS.has(String(name || "").toLowerCase());
}
/** Extra Host-header names the operator allows (set when binding to a LAN). */
function allowedHostnames() {
return (process.env.DASHBOARD_ALLOWED_HOSTS || "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
/** Strip the port from a Host header, preserving bracketed IPv6 literals. */
function hostnameOf(hostHeader) {
const h = String(hostHeader || "");
if (h.startsWith("[")) {
const end = h.indexOf("]");
return end >= 0 ? h.slice(0, end + 1).toLowerCase() : h.toLowerCase();
}
return h.split(":")[0].toLowerCase();
}
function isHostAllowed(hostHeader) {
const name = hostnameOf(hostHeader);
return isLoopbackHostname(name) || allowedHostnames().includes(name);
}
/**
* Express middleware: reject requests whose Host header isn't loopback (or an
* operator-allowlisted name). This is the primary defense against DNS-rebinding
* — a rebound attacker domain arrives with its own Host (e.g. evil.example) and
* is refused even though the TCP connection is local→local.
*/
function hostGuard(req, res, next) {
if (isHostAllowed(req.headers.host)) return next();
return res.status(403).json({ error: { code: "EBADHOST", message: "host not allowed" } });
}
/**
* CORS options: allow same-origin / no-Origin (curl, the server's own client)
* and loopback origins; refuse everything else (so a cross-origin page cannot
* read responses). Credentials stay off — the API is token- or trust-gated, not
* cookie-authed.
*/
function corsOptions() {
return {
origin(origin, cb) {
if (!origin) return cb(null, true);
try {
const u = new URL(origin);
if (
isLoopbackHostname(u.hostname) ||
allowedHostnames().includes(u.hostname.toLowerCase())
) {
return cb(null, true);
}
} catch {
/* malformed Origin → treat as disallowed */
}
return cb(null, false);
},
credentials: false,
};
}
/** The configured auth token, or null when auth is disabled (the default). */
function getDashboardToken() {
const t = process.env.DASHBOARD_TOKEN;
return typeof t === "string" && t.length > 0 ? t : null;
}
function tokensMatch(provided, expected) {
if (typeof provided !== "string" || provided.length === 0) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function extractToken(req) {
const auth = req.headers.authorization;
if (typeof auth === "string" && auth.startsWith("Bearer ")) return auth.slice(7);
const header = req.headers["x-dashboard-token"];
if (typeof header === "string" && header) return header;
if (req.query && typeof req.query.token === "string") return req.query.token;
return null;
}
// API subpaths exempt from the token gate even when a token is set:
// /health, /openapi.json, /docs — harmless metadata / docs.
// /hooks — local Claude Code hook ingestion (the hook handler posts to
// loopback and carries no token); loopback bind already protects it.
const TOKEN_EXEMPT_PREFIXES = ["/health", "/openapi.json", "/docs", "/hooks"];
/**
* Express middleware (mount at "/api"): when DASHBOARD_TOKEN is set, require a
* matching bearer token on every API route except the exempt prefixes. A no-op
* when no token is configured — preserving the zero-config loopback default.
*/
function tokenGuard(req, res, next) {
const expected = getDashboardToken();
if (!expected) return next();
if (TOKEN_EXEMPT_PREFIXES.some((p) => req.path === p || req.path.startsWith(p + "/"))) {
return next();
}
if (tokensMatch(extractToken(req), expected)) return next();
return res
.status(401)
.json({ error: { code: "EUNAUTHORIZED", message: "missing or invalid dashboard token" } });
}
/**
* WebSocket upgrade auth. When a token is configured, the client must pass it as
* `?token=` (or an x-dashboard-token header). No-op when auth is disabled.
*/
function isWebSocketAuthorized(req) {
const expected = getDashboardToken();
if (!expected) return true;
try {
const u = new URL(req.url, "http://localhost");
if (tokensMatch(u.searchParams.get("token"), expected)) return true;
} catch {
/* fall through */
}
const header = req.headers["x-dashboard-token"];
if (typeof header === "string" && tokensMatch(header, expected)) return true;
return false;
}
module.exports = {
LOOPBACK_HOSTS,
resolveHost,
isLoopbackHostname,
allowedHostnames,
hostnameOf,
isHostAllowed,
hostGuard,
corsOptions,
getDashboardToken,
tokenGuard,
isWebSocketAuthorized,
// exported for tests
tokensMatch,
extractToken,
};
+295
View File
@@ -0,0 +1,295 @@
/**
* @file server-info.js
* @description Live discovery of every running dashboard server's TCP port.
*
* The conventional port is 4820, and a plain `npm start` setup almost always
* binds it. But more than one dashboard can run on a single machine — most
* commonly the macOS desktop app side-by-side with `npm run dev`. The hook
* handler fans out to every live dashboard that uses a **different** SQLite
* data directory. Servers sharing the same `dataDir` receive hooks through a
* single lowest-port ingest target so events are never duplicated.
*
* The on-disk file is a JSON document under the Claude Code home directory.
* Every server writes its own entry on startup, prunes any stale entries it
* finds, and the hook handler reads the file and fans out one POST per live
* entry. Stale entries (process gone) are dropped on every read.
*
* Backwards compatibility: the file always carries the **legacy** single-
* record fields (`port`, `pid`, `startedAt`) at its root, set to the most
* recently started live server. Older hook handlers — e.g. the one bundled
* inside a previously-installed `.app` that predates this multi-server
* format — still parse the file successfully and reach at least one live
* server. The new shape lives under `servers: [...]`.
*
* Every function here is best-effort and never throws: discovery must never
* block server startup, and the hook handler must never fail because of it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { getClaudeHome, getDataDir } = require("./claude-home");
/** Conventional dashboard port — used when discovery yields nothing. */
const DEFAULT_PORT = 4820;
/** Absolute path of the discovery file. */
function getServerInfoPath() {
return path.join(getClaudeHome(), ".agent-dashboard.json");
}
/**
* Canonical absolute path for comparing data directories across processes.
* Falls back to `path.resolve` when the directory does not exist yet.
*
* @param {string} dir
* @returns {string}
*/
function normalizeDataDir(dir) {
if (!dir || typeof dir !== "string") return "";
try {
return fs.realpathSync(dir);
} catch {
return path.resolve(dir);
}
}
/**
* Grouping key for hook-ingest deduplication. Entries without `dataDir` are
* treated as unique (legacy servers before this field existed).
*
* @param {{ port: number, dataDir?: string }} server
* @returns {string}
*/
function ingestGroupKey(server) {
if (server.dataDir) return normalizeDataDir(server.dataDir);
return `__legacy__:${server.port}`;
}
/**
* Read the discovery file and return its `servers` list, normalised. Handles
* both the new array shape and the legacy single-record shape so a file
* written by an older server is still understood.
*
* @returns {Array<{port: number, pid: number, startedAt: string}>}
*/
function readInfoFile() {
try {
const raw = fs.readFileSync(getServerInfoPath(), "utf8");
const parsed = JSON.parse(raw);
if (Array.isArray(parsed.servers)) {
return parsed.servers.filter((s) => s && Number.isInteger(s.port));
}
if (Number.isInteger(parsed.port)) {
// Legacy single-record file written by a server that predates this
// format. Treat the root object as the lone server entry.
return [{ port: parsed.port, pid: parsed.pid, startedAt: parsed.startedAt }];
}
return [];
} catch {
return [];
}
}
/**
* Whether a process is still running. `process.kill(pid, 0)` sends no signal;
* it only probes existence. EPERM means the process exists but is owned by
* another user — still "alive" for our purposes.
*
* @param {number} pid
* @returns {boolean}
*/
function isPidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return Boolean(err) && err.code === "EPERM";
}
}
/** Most recently started entry — used to populate the legacy root fields. */
function mostRecent(servers) {
return servers.reduce((a, b) => {
const at = Date.parse(a.startedAt) || 0;
const bt = Date.parse(b.startedAt) || 0;
return bt > at ? b : a;
});
}
/**
* Write `{ servers, ...legacy }` to disk via temp file + atomic rename. The
* read-modify-write here is not file-system locked — if two servers race to
* write at the exact same millisecond one entry may be momentarily lost; the
* loser's next write (or any read that triggers a prune) self-heals.
*/
function persist(servers) {
if (servers.length === 0) {
try {
fs.unlinkSync(getServerInfoPath());
} catch {
/* already gone */
}
return;
}
const recent = mostRecent(servers);
const payload = JSON.stringify(
{
// Legacy fields so an older hook handler (e.g. one bundled inside a
// previously-installed .app that predates the multi-server format)
// still resolves to a reachable port.
port: recent.port,
pid: recent.pid,
startedAt: recent.startedAt,
// The full list of live servers — the field new readers consume.
servers,
},
null,
2
);
const finalPath = getServerInfoPath();
const tmpPath = `${finalPath}.${process.pid}.tmp`;
fs.writeFileSync(tmpPath, payload);
fs.renameSync(tmpPath, finalPath);
}
/**
* Record the live server port so the hook handler (and any other local
* consumer) can find it. Other servers' entries are preserved; dead entries
* are pruned. Best-effort — a failure here never interrupts server startup.
*
* @param {number} port - The port the HTTP server is listening on.
*/
function writeServerInfo(port) {
if (!Number.isInteger(port) || port <= 0) return;
try {
const dir = getClaudeHome();
fs.mkdirSync(dir, { recursive: true });
const existing = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && s.pid !== process.pid && isPidAlive(s.pid)
);
const ours = {
port,
pid: process.pid,
startedAt: new Date().toISOString(),
dataDir: normalizeDataDir(getDataDir()),
};
persist([...existing, ours]);
} catch {
// Discovery is an optimization, not a requirement — never block startup.
}
}
/** Remove this process's entry from the file. Safe to call when absent. */
function removeServerInfo() {
try {
const remaining = readInfoFile().filter((s) => s.pid !== process.pid);
persist(remaining);
} catch {
// Already gone, never written, or unreadable — nothing to do.
}
}
/**
* Resolve every live dashboard server's port. Result is ordered most-recent
* last (the order entries appear in the file).
*
* 1. `CLAUDE_DASHBOARD_PORT` — explicit operator override; returned as the
* sole target so a test or one-off override doesn't fan out.
* 2. Live entries from the discovery file, pruned by PID liveness.
* 3. `[DEFAULT_PORT]` (`[4820]`) — the conventional fallback when nothing
* else resolves.
*
* @returns {number[]}
*/
function resolveAllDashboardPorts() {
const envPort = parseInt(process.env.CLAUDE_DASHBOARD_PORT || "", 10);
if (Number.isInteger(envPort) && envPort > 0) return [envPort];
const live = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && isPidAlive(s.pid)
);
if (live.length > 0) {
// Dedupe by port in case the same port appears twice (defensive).
return [...new Set(live.map((s) => s.port))];
}
return [DEFAULT_PORT];
}
/**
* Ports that should receive hook POSTs. When several live servers share the
* same SQLite data directory, only the lowest port per directory is returned
* so parallel instances (Docker + dev, two terminals on the same DB) never
* double-ingest events.
*
* @returns {number[]}
*/
function resolveHookIngestPorts() {
const envPort = parseInt(process.env.CLAUDE_DASHBOARD_PORT || "", 10);
if (Number.isInteger(envPort) && envPort > 0) return [envPort];
const live = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && isPidAlive(s.pid)
);
if (live.length === 0) return [DEFAULT_PORT];
const byDataDir = new Map();
for (const server of live) {
const key = ingestGroupKey(server);
const prev = byDataDir.get(key);
if (!prev || server.port < prev.port) {
byDataDir.set(key, server);
}
}
return [...byDataDir.values()].map((s) => s.port).sort((a, b) => a - b);
}
/**
* Other live dashboard processes using the same SQLite data directory as this
* one. Used for startup warnings when multiple UIs point at one database.
*
* @returns {Array<{port: number, pid: number, startedAt: string}>}
*/
function peersSharingDataDir() {
try {
const mine = normalizeDataDir(getDataDir());
if (!mine) return [];
return readInfoFile().filter((s) => {
if (!Number.isInteger(s.port) || s.port <= 0) return false;
if (s.pid === process.pid) return false;
if (!isPidAlive(s.pid)) return false;
if (!s.dataDir) return false;
return normalizeDataDir(s.dataDir) === mine;
});
} catch {
return [];
}
}
/**
* Single-port helper kept for callers that have always asked the file for
* "the" port (e.g. legacy code paths and tests). Returns the first live
* server's port, or the default if none are alive.
*
* @returns {number}
*/
function resolveDashboardPort() {
return resolveAllDashboardPorts()[0] ?? DEFAULT_PORT;
}
module.exports = {
DEFAULT_PORT,
getServerInfoPath,
writeServerInfo,
removeServerInfo,
resolveDashboardPort,
resolveAllDashboardPorts,
resolveHookIngestPorts,
peersSharingDataDir,
// Exported for tests.
normalizeDataDir,
ingestGroupKey,
};
+116
View File
@@ -0,0 +1,116 @@
/**
* @file Process-liveness probe for Claude Code sessions. Answers "could any
* running `claude` CLI process own this session?" by listing live claude
* processes and their working directories. Used by the hooks watchdog to
* reap sessions whose SessionEnd hook was lost because the dashboard was not
* running when the user quit (e.g. Ctrl+C while the server was down) — the
* only signal that a session ended is that hook, so a missed one previously
* left the session stuck in Waiting until the 3 h stale sweep.
*
* Fail-safe by design: whenever the probe cannot produce a trustworthy
* answer it reports `available: false` and the caller must change nothing.
* That covers Windows (no probe implementation), containers (host processes
* are invisible, so an empty process list would be a lie), missing `ps` /
* `lsof` binaries, and the DASHBOARD_LIVENESS_PROBE=0 escape hatch for
* setups where hooks arrive from another machine.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const { isInsideContainer } = require("../../scripts/install-hooks");
const UNAVAILABLE = () => ({ available: false, cwds: new Set() });
/**
* True when a `ps` args string is a Claude Code CLI process. Matches the
* bare binary (`claude`, `/usr/local/bin/claude`) and interpreter-launched
* shims (`node /path/to/claude`, `bun /path/to/claude`). The basename must
* be exactly "claude" so lookalikes (claude-mem, Claude.app's `Claude`
* binary, this project's own processes) never match.
*/
function isClaudeCommand(args) {
if (typeof args !== "string") return false;
const tokens = args.trim().split(/\s+/);
if (tokens.length === 0 || !tokens[0]) return false;
if (path.basename(tokens[0]) === "claude") return true;
const interpreter = path.basename(tokens[0]);
if ((interpreter === "node" || interpreter === "bun") && tokens[1]) {
return path.basename(tokens[1]) === "claude";
}
return false;
}
/** True when the probe is explicitly disabled via env. */
function probeDisabledByEnv() {
const raw = (process.env.DASHBOARD_LIVENESS_PROBE || "").trim().toLowerCase();
return raw === "0" || raw === "false" || raw === "no" || raw === "off";
}
/**
* Enumerate the working directories of every live `claude` CLI process.
*
* @returns {{ available: boolean, cwds: Set<string> }} `available: false`
* means "no trustworthy answer — do not act"; an `available: true` result
* with an empty set genuinely means no claude process is running.
*/
function probeLiveCwds() {
if (probeDisabledByEnv()) return UNAVAILABLE();
if (process.platform === "win32") return UNAVAILABLE();
if (isInsideContainer()) return UNAVAILABLE();
let psOut;
try {
psOut = execFileSync("ps", ["-Ao", "pid=,args="], {
encoding: "utf8",
timeout: 5_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch {
return UNAVAILABLE();
}
const pids = [];
for (const line of psOut.split("\n")) {
const m = line.match(/^\s*(\d+)\s+(.*)$/);
if (m && isClaudeCommand(m[2])) pids.push(m[1]);
}
const cwds = new Set();
if (pids.length === 0) return { available: true, cwds };
if (process.platform === "linux") {
// /proc is authoritative and needs no external binary.
for (const pid of pids) {
try {
cwds.add(path.resolve(fs.readlinkSync(`/proc/${pid}/cwd`)));
} catch {
/* process exited between ps and readlink — skip */
}
}
return { available: true, cwds };
}
// macOS (and other BSD-likes): resolve each pid's cwd via lsof. `-Fn`
// machine format emits `p<pid>` / `f cwd` / `n<path>` records.
let lsofOut;
try {
lsofOut = execFileSync("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
encoding: "utf8",
timeout: 10_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch (err) {
// lsof exits non-zero when SOME of the pids vanished between ps and
// lsof but still prints records for the rest — keep that partial
// output. No stdout at all (binary missing, hard failure) → no answer.
lsofOut = err && typeof err.stdout === "string" && err.stdout ? err.stdout : null;
if (lsofOut === null) return UNAVAILABLE();
}
for (const line of lsofOut.split("\n")) {
if (line.startsWith("n") && line.length > 1) cwds.add(path.resolve(line.slice(1)));
}
return { available: true, cwds };
}
module.exports = { probeLiveCwds, isClaudeCommand };
+66
View File
@@ -0,0 +1,66 @@
/**
* @file source-filter.js
* @description Shared helper for the "data scope" feature: restricting a query
* to sessions collected from a chosen set of machines (see server/db.js
* `sessions.source` and server/lib/remote-sync.js).
*
* The client passes `?sources=local,src_abc,...` on any data endpoint. Absent or
* empty means "all sources" (no filter) so every existing caller and the
* zero-config default are unaffected. This module turns that query param into a
* SQL fragment that is safe to append to any WHERE clause — either directly on
* `sessions.source`, or, for tables that only carry a `session_id`, as a
* subquery so complex aggregate SQL (stats, analytics) needs only one extra AND.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/**
* Parse the `sources` query param into a de-duplicated list, or null for
* "all sources" (no filtering).
* @param {import("express").Request} req
* @returns {string[]|null}
*/
function parseSources(req) {
const raw = req.query ? req.query.sources : undefined;
if (typeof raw !== "string") return null;
const list = [
...new Set(
raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
),
];
return list.length > 0 ? list : null;
}
/**
* Filter directly on a `source` column (used when `sessions` is in the query).
* @param {string[]|null} sources result of parseSources
* @param {string} [col] the qualified source column (default "s.source")
* @returns {{clause:string, params:string[]}} `clause` is "" when no filter
*/
function sourceColumnClause(sources, col = "s.source") {
if (!sources || sources.length === 0) return { clause: "", params: [] };
const placeholders = sources.map(() => "?").join(",");
return { clause: `${col} IN (${placeholders})`, params: sources };
}
/**
* Filter by session origin when only a `session_id` column is available, via a
* subquery against `sessions`. Lets stats/analytics/events/agents scope by
* source with a single extra AND and no FROM/GROUP BY changes.
* @param {string[]|null} sources result of parseSources
* @param {string} sessionIdCol the qualified session-id column (e.g. "e.session_id")
* @returns {{clause:string, params:string[]}} `clause` is "" when no filter
*/
function sessionIdInSourcesClause(sources, sessionIdCol) {
if (!sources || sources.length === 0) return { clause: "", params: [] };
const placeholders = sources.map(() => "?").join(",");
return {
clause: `${sessionIdCol} IN (SELECT id FROM sessions WHERE source IN (${placeholders}))`,
params: sources,
};
}
module.exports = { parseSources, sourceColumnClause, sessionIdInSourcesClause };
+146
View File
@@ -0,0 +1,146 @@
/**
* @file Matches hook events against pipeline-defined stage-detection rules.
* Inputs and templates are untrusted, so each exported function is total and
* compiled regular expressions are cached once for each pipeline object.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const compiledPipelines = new WeakMap();
// Only fields that identify WHAT a tool did, never editor payload like
// old_string/new_string, which can be whole code blocks.
const FLATTEN_KEYS = new Set([
"command",
"file_path",
"skill",
"prompt",
"pattern",
"description",
"subagent_type",
]);
const SIGNAL_MAX = 120;
/** Collapse whitespace and cap to a bounded, readable length. */
function capSignal(text) {
const collapsed = text.replace(/\s+/g, " ").trim();
return collapsed.length > SIGNAL_MAX ? `${collapsed.slice(0, SIGNAL_MAX)}` : collapsed;
}
/** Characters of surrounding context kept on each side of a matched span. */
const SIGNAL_CONTEXT = 24;
/**
* The span the rule actually matched, plus a little context — the whole
* flattened input is usually a long shell line whose interesting part is a few
* words in the middle (`cd /very/long/path && npm run test:server 2>&1 | tail`).
* A rule with no regex matched on the tool name alone and has no span, so the
* caller keeps the flattened input.
*/
function matchedSpan(regex, input) {
if (!regex) return null;
// `regex` may carry /g from a user template; lastIndex would make exec()
// stateful across calls, so search from a known position every time.
regex.lastIndex = 0;
const m = regex.exec(input);
if (!m || typeof m.index !== "number") return null;
const start = Math.max(0, m.index - SIGNAL_CONTEXT);
const end = Math.min(input.length, m.index + m[0].length + SIGNAL_CONTEXT);
const prefix = start > 0 ? "…" : "";
const suffix = end < input.length ? "…" : "";
return `${prefix}${input.slice(start, end)}${suffix}`;
}
/** Return known identifying string fields from a tool input without recursively walking it. */
function flattenInput(toolInput) {
try {
if (typeof toolInput === "string") return toolInput;
if (toolInput === null || typeof toolInput !== "object") return "";
const strings = [];
if (Array.isArray(toolInput)) {
for (const value of toolInput) {
if (typeof value === "string") strings.push(value);
else if (value && typeof value === "object") {
for (const [key, nestedValue] of Object.entries(value)) {
if (FLATTEN_KEYS.has(key) && typeof nestedValue === "string") strings.push(nestedValue);
}
}
}
} else {
for (const [key, value] of Object.entries(toolInput)) {
if (FLATTEN_KEYS.has(key) && typeof value === "string") strings.push(value);
}
}
return strings.join(" ");
} catch {
return "";
}
}
/** Compile valid node rules once, omitting malformed nodes and regex patterns. */
function compileRules(pipeline) {
if (!pipeline || typeof pipeline !== "object") return [];
const cached = compiledPipelines.get(pipeline);
if (cached) return cached;
let nodes = [];
try {
nodes = Array.isArray(pipeline.nodes) ? pipeline.nodes : [];
} catch {
return [];
}
const compiled = nodes.map((node) => {
const rules = [];
try {
const detectRules = Array.isArray(node && node.detect) ? node.detect : [];
for (const rule of detectRules) {
if (!rule || typeof rule !== "object" || typeof rule.tool !== "string") continue;
if (rule.match === undefined) {
rules.push({ tool: rule.tool, regex: null });
continue;
}
if (typeof rule.match !== "string") continue;
try {
rules.push({ tool: rule.tool, regex: new RegExp(rule.match) });
} catch {
// A user-supplied invalid regex must never block hook ingestion.
}
}
} catch {
// Ignore a malformed node while preserving the remaining template.
}
return { nodeId: node && node.id, rules };
});
compiledPipelines.set(pipeline, compiled);
return compiled;
}
/** Infer the last matching pipeline stage from one hook event, or return null. */
function detect(pipeline, event) {
try {
if (!event || typeof event.tool_name !== "string" || !event.tool_name) return null;
const input = flattenInput(event.tool_input);
const fallback = input || event.tool_name;
let match = null;
for (const node of compileRules(pipeline)) {
if (typeof node.nodeId !== "string" || !node.nodeId) continue;
for (const rule of node.rules) {
if (rule.tool !== event.tool_name) continue;
if (!rule.regex || rule.regex.test(input)) {
const span = matchedSpan(rule.regex, input);
match = { nodeId: node.nodeId, signal: `\`${capSignal(span || fallback)}\`` };
break;
}
}
}
return match;
} catch {
return null;
}
}
module.exports = { flattenInput, compileRules, detect };
+40
View File
@@ -0,0 +1,40 @@
/**
* @file stream-json-parser.js
* @description Newline-delimited JSON line buffer for parsing `claude
* --output-format stream-json` output. Reassembles arbitrarily chunked stdout
* into discrete JSON envelopes (one per line). Robust to partial writes;
* malformed lines are reported via onError but never throw.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
function createLineParser(onObject, onError) {
let buf = "";
return {
push(chunk) {
buf += chunk;
let nlIdx;
while ((nlIdx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nlIdx).trim();
buf = buf.slice(nlIdx + 1);
if (!line) continue;
try {
onObject(JSON.parse(line));
} catch (err) {
if (typeof onError === "function") onError(err, line);
}
}
},
flush() {
const tail = buf.trim();
buf = "";
if (!tail) return;
try {
onObject(JSON.parse(tail));
} catch (err) {
if (typeof onError === "function") onError(err, tail);
}
},
};
}
module.exports = { createLineParser };
+130
View File
@@ -0,0 +1,130 @@
/**
* @file Shared helpers for normalizing Claude transcript `usage` records into
* per-bucket token tallies. Used by BOTH ingestion paths — the live server-side
* parser (`server/lib/transcript-cache.js`) and the history importer
* (`scripts/import-history.js`) — so the two stay in lockstep.
*
* A "bucket" is the unit cost is computed against: tokens are grouped by
* (model, speed, inference_geo, service_tier) because those four dimensions
* change the per-token RATE (fast mode, US data residency, Batch API). The
* dimensions are normalized to the small set of values that actually move
* price; anything unknown collapses to the standard/global default so old
* transcripts (which lack `speed` / `inference_geo` / `cache_creation`
* breakdown / `server_tool_use`) price exactly as they did before.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// Separator for composite bucket keys — U+0001 (SOH) cannot occur in a model id.
const BUCKET_SEP = String.fromCharCode(1);
/** Pricing-relevant speed. Anything other than the fast research-preview tier is standard. */
function normalizeSpeed(usage) {
return usage && usage.speed === "fast" ? "fast" : "standard";
}
/**
* Pricing-relevant inference geography. Only US-pinned routing carries the 1.1x
* data-residency premium; "global", "not_available", and absent all map to the
* standard "global" rate.
*/
function normalizeGeo(usage) {
return usage && usage.inference_geo === "us" ? "us" : "global";
}
/** Pricing-relevant service tier. Only "batch" changes the rate (50% off). */
function normalizeTier(usage) {
return usage && usage.service_tier === "batch" ? "batch" : "standard";
}
/** Composite bucket key — stable string usable as an object property. */
function bucketKey(model, speed, geo, tier) {
return [model, speed, geo, tier].join(BUCKET_SEP);
}
/** A zeroed bucket carrying its four pricing dimensions. */
function emptyBucket(model, speed, geo, tier) {
return {
model,
speed,
geo,
tier,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0, // TOTAL ephemeral cache-creation tokens (5m + 1h)
cacheWrite1h: 0, // subset of cacheWrite that is the 1h tier; 5m = cacheWrite - cacheWrite1h
webSearch: 0, // server_tool_use.web_search_requests (billed per 1k)
webFetch: 0, // server_tool_use.web_fetch_requests (free; tracked for visibility)
codeExec: 0, // server_tool_use.code_execution_requests (time-billed; estimated)
};
}
/**
* Pull the numeric token / request fields out of a single `usage` record.
* Tolerant of the older shape: when `cache_creation` breakdown is absent the
* whole cache-write amount is treated as 5m (cacheWrite1h = 0), and a missing
* `server_tool_use` yields zero tool requests.
*/
function extractUsageFields(usage) {
if (!usage || typeof usage !== "object") {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cacheWrite1h: 0,
webSearch: 0,
webFetch: 0,
codeExec: 0,
};
}
const cc =
usage.cache_creation && typeof usage.cache_creation === "object" ? usage.cache_creation : null;
const ephem5m = cc ? cc.ephemeral_5m_input_tokens || 0 : 0;
const ephem1h = cc ? cc.ephemeral_1h_input_tokens || 0 : 0;
// Prefer the explicit total; fall back to the breakdown sum when only that is present.
const cacheWrite =
usage.cache_creation_input_tokens != null
? usage.cache_creation_input_tokens || 0
: ephem5m + ephem1h;
// Never let the 1h subset exceed the recorded total (guards malformed records).
const cacheWrite1h = Math.min(ephem1h, cacheWrite);
const stu =
usage.server_tool_use && typeof usage.server_tool_use === "object"
? usage.server_tool_use
: null;
return {
input: usage.input_tokens || 0,
output: usage.output_tokens || 0,
cacheRead: usage.cache_read_input_tokens || 0,
cacheWrite,
cacheWrite1h,
webSearch: stu ? stu.web_search_requests || 0 : 0,
webFetch: stu ? stu.web_fetch_requests || 0 : 0,
codeExec: stu ? stu.code_execution_requests || 0 : 0,
};
}
/** Add the numeric fields of `src` into `target` in place. */
function accumulateBucket(target, src) {
target.input += src.input || 0;
target.output += src.output || 0;
target.cacheRead += src.cacheRead || 0;
target.cacheWrite += src.cacheWrite || 0;
target.cacheWrite1h += src.cacheWrite1h || 0;
target.webSearch += src.webSearch || 0;
target.webFetch += src.webFetch || 0;
target.codeExec += src.codeExec || 0;
return target;
}
module.exports = {
BUCKET_SEP,
normalizeSpeed,
normalizeGeo,
normalizeTier,
bucketKey,
emptyBucket,
extractUsageFields,
accumulateBucket,
};
+793
View File
@@ -0,0 +1,793 @@
/**
* @file TranscriptCache class for efficient extraction of token usage and compaction data from JSONL transcript files, with stat-based caching and incremental reads to handle append-only growth without re-reading the entire file. Also extracts API error entries and turn duration system messages for enhanced analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const {
bucketKey,
emptyBucket,
extractUsageFields,
normalizeSpeed,
normalizeGeo,
normalizeTier,
accumulateBucket,
} = require("./token-usage");
const MAX_CACHE_ENTRIES = 200;
// Marker text Claude Code writes into the transcript when a turn is cancelled
// by the user (Esc). The synthetic entry is `type:"user"` and also carries an
// `interruptedMessageId` field; we accept either signal so detection survives
// minor format drift. No hook fires on interrupt, so this is the only on-disk
// evidence the watchdog can use to un-stick a session left in "working".
const INTERRUPT_RE = /\[Request interrupted by user/i;
// True when the transcript's tail is a user-interrupt that was never followed
// by real turn activity (a new prompt or model output). Both timestamps come
// from Claude Code's clock, so the comparison is immune to the server/transcript
// skew that breaks a sub-second pre-output Esc. `>=` so an interrupt that ties
// the last activity (interrupt written in the same instant) still counts.
function computePendingInterrupt(lastInterruptTs, lastTurnTs) {
if (!lastInterruptTs) return false;
if (!lastTurnTs) return true;
return lastInterruptTs >= lastTurnTs;
}
function hasInterruptText(message) {
if (!message || typeof message !== "object") return false;
const c = message.content;
if (typeof c === "string") return INTERRUPT_RE.test(c);
if (Array.isArray(c)) {
for (const block of c) {
if (block && typeof block.text === "string" && INTERRUPT_RE.test(block.text)) return true;
}
}
return false;
}
// Hard cap on the length of each per-entry growable array (turnDurations,
// errors, compaction.entries, usageExtras.{service_tiers,speeds,inference_geos}).
// Past this point we keep the *tail* — the most recent N items — so the
// cache reflects current state. Older items are NOT lost from the system:
// they are already persisted to the events table by routes/hooks.js, with
// dedup logic that prevents re-insertion when the cache re-reads them.
// Configurable via TRANSCRIPT_CACHE_MAX_ARRAY_LEN env var.
const MAX_ARRAY_LEN = (() => {
const raw = parseInt(process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN, 10);
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
})();
// Watermark for in-flight trimming during _consumeLine. We trim back to
// MAX_ARRAY_LEN whenever an array reaches 2*MAX_ARRAY_LEN, so a full-file
// parse cannot accumulate an unbounded transient before _finalizeState runs.
// Amortized O(N): each item is touched by a splice at most ~once.
const PARSE_TRIM_WATERMARK = MAX_ARRAY_LEN * 2;
// Cap on the captured first-user-message text. 500 chars matches the task
// truncation the hook ingestor already applies to subagent prompts, so the
// descriptor can be reused verbatim as an agent task downstream.
const FIRST_USER_MESSAGE_MAX_LEN = 500;
// Synthetic user entries whose text is CLI plumbing, not something the human
// typed: local slash-command invocations/output and the caveat preamble
// Claude Code writes before locally-generated messages. These must never
// become a session descriptor.
const SYNTHETIC_USER_TEXT_RE =
/^<(?:command-name|command-message|local-command-stdout|local-command-caveat)>/;
/**
* Extract the human-typed text of a user transcript entry, or null when the
* entry is not a real prompt: tool-result entries, meta/caveat lines, local
* slash-command plumbing, compact summaries, and user-interrupt markers are
* all skipped. Shared with scripts/import-history.js so imported and live
* sessions derive the identical descriptor.
*/
function extractFirstUserText(entry) {
if (entry.isMeta || entry.isCompactSummary) return null;
if (entry.interruptedMessageId != null || hasInterruptText(entry.message)) return null;
const msg = entry.message;
if (!msg || typeof msg !== "object" || msg.role !== "user") return null;
const content = msg.content;
let text = null;
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
// Tool-result entries are `role:"user"` too — skip any entry carrying a
// tool_result block rather than mining text out of a mixed payload.
if (content.some((b) => b && b.type === "tool_result")) return null;
text = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text)
.join(" ");
}
if (typeof text !== "string") return null;
// Collapse newlines/runs of whitespace so the descriptor reads as one line.
text = text.replace(/\s+/g, " ").trim();
if (!text || SYNTHETIC_USER_TEXT_RE.test(text)) return null;
return text.length > FIRST_USER_MESSAGE_MAX_LEN
? text.slice(0, FIRST_USER_MESSAGE_MAX_LEN)
: text;
}
class TranscriptCache {
constructor(maxEntries = MAX_CACHE_ENTRIES) {
this._cache = new Map();
this._maxEntries = maxEntries;
this._hits = 0;
this._misses = 0;
}
/**
* Extract token usage and compaction data from a JSONL transcript file.
* Uses stat-based caching with incremental reads for append-only growth.
* Returns null if file doesn't exist or has no data.
*/
extract(transcriptPath) {
if (!transcriptPath) return null;
try {
let stat;
try {
stat = fs.statSync(transcriptPath);
} catch {
return null;
}
const key = transcriptPath;
const cached = this._cache.get(key);
// Cache hit: file unchanged (same mtime + size)
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
this._hits++;
return cached.result;
}
this._misses++;
// File shrunk or first read → full re-read
if (!cached || stat.size < cached.bytesRead) {
const result = this._fullRead(transcriptPath);
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
}
// File grew → incremental read from last position
if (stat.size > cached.bytesRead) {
const incremental = this._streamRange(transcriptPath, cached.bytesRead, stat.size);
if (incremental) {
const merged = this._merge(cached, incremental);
const hasTokens = Object.keys(merged.tokensByModel).length > 0;
const hasTurnDurations = merged.turnDurations && merged.turnDurations.length > 0;
const hasUsageExtras =
merged.usageExtras &&
(merged.usageExtras.service_tiers.length > 0 ||
merged.usageExtras.speeds.length > 0 ||
merged.usageExtras.inference_geos.length > 0);
const result = {
tokensByModel: hasTokens ? merged.tokensByModel : null,
compaction: merged.compaction,
errors: merged.errors,
turnDurations: hasTurnDurations ? merged.turnDurations : null,
thinkingBlockCount: merged.thinkingBlockCount || 0,
usageExtras: hasUsageExtras ? merged.usageExtras : null,
latestModel: merged.latestModel || null,
customTitle: merged.customTitle || null,
aiTitle: merged.aiTitle || null,
firstUserMessage: merged.firstUserMessage || null,
lastInterruptTs: merged.lastInterruptTs || null,
lastTurnTs: merged.lastTurnTs || null,
pendingInterrupt: computePendingInterrupt(merged.lastInterruptTs, merged.lastTurnTs),
};
if (
!result.tokensByModel &&
!result.compaction &&
!result.errors &&
!result.turnDurations &&
!result.thinkingBlockCount &&
!result.usageExtras &&
!result.latestModel &&
!result.customTitle &&
!result.aiTitle &&
!result.firstUserMessage &&
!result.lastInterruptTs &&
!result.lastTurnTs
) {
this._set(key, {
mtimeMs: stat.mtimeMs,
size: stat.size,
bytesRead: stat.size,
result: null,
});
return null;
}
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
}
// Only whitespace/newlines appended
this._set(key, {
...cached,
mtimeMs: stat.mtimeMs,
size: stat.size,
bytesRead: stat.size,
});
return cached.result;
}
// Same size, different mtime — content may have been rewritten (compaction)
const result = this._fullRead(transcriptPath);
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
} catch {
return null;
}
}
/**
* Extract only compaction entries from a JSONL file.
* Replacement for findCompactionsInFile — uses the same cache, no duplicate reads.
*/
extractCompactions(transcriptPath) {
const result = this.extract(transcriptPath);
if (!result || !result.compaction) return [];
return result.compaction.entries.map((e) => ({ ...e }));
}
/**
* Full re-read using chunked streaming. Avoids materializing the whole file
* as a single JS string, so files larger than V8's max string length
* (~512 MiB on 64-bit Node) parse without aborting the process with
* "FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal".
*/
_fullRead(filePath) {
let size;
try {
size = fs.statSync(filePath).size;
} catch {
return null;
}
return this._streamRange(filePath, 0, size);
}
/**
* Sync chunked range reader + line parser.
* Reads [startOffset, endOffset) in fixed-size chunks, splits on 0x0A bytes,
* decodes each complete line as UTF-8 (safe: 0x0A never appears inside a
* UTF-8 multibyte sequence), and feeds it to _consumeLine. Partial trailing
* bytes between chunks are held in a byte buffer so multibyte characters
* straddling a chunk boundary are not corrupted. Never builds a string
* larger than a single line, so V8 string-length limits cannot be hit.
*/
_streamRange(filePath, startOffset, endOffset) {
const state = this._initParseState();
if (endOffset <= startOffset) return this._finalizeState(state);
const CHUNK = 4 * 1024 * 1024; // 4 MiB
const MAX_PENDING = 64 * 1024 * 1024; // hard cap on a single line
const buf = Buffer.allocUnsafe(CHUNK);
let pending = null; // bytes of partial trailing line not yet terminated by \n
let pendingLen = 0;
let pos = startOffset;
let fd;
try {
try {
fd = fs.openSync(filePath, "r");
} catch {
return this._finalizeState(state);
}
while (pos < endOffset) {
const want = Math.min(CHUNK, endOffset - pos);
let got;
try {
got = fs.readSync(fd, buf, 0, want, pos);
} catch {
break;
}
if (got <= 0) break;
pos += got;
let lineStart = 0;
for (let i = 0; i < got; i++) {
if (buf[i] !== 0x0a) continue;
let line;
if (pendingLen) {
const need = pendingLen + (i - lineStart);
const lineBuf = Buffer.allocUnsafe(need);
pending.copy(lineBuf, 0, 0, pendingLen);
buf.copy(lineBuf, pendingLen, lineStart, i);
line = lineBuf.toString("utf8");
pending = null;
pendingLen = 0;
} else {
line = buf.toString("utf8", lineStart, i);
}
if (line.length && line.charCodeAt(line.length - 1) === 13) {
line = line.slice(0, -1); // strip CR
}
if (line) this._consumeLine(line, state);
lineStart = i + 1;
}
if (lineStart < got) {
const tailLen = got - lineStart;
const newLen = pendingLen + tailLen;
if (newLen > MAX_PENDING) {
// Pathological single line — drop accumulated bytes and skip
// forward to the next newline rather than OOM. Loss is bounded
// to one malformed line.
pending = null;
pendingLen = 0;
} else {
if (!pending) {
pending = Buffer.allocUnsafe(Math.max(newLen, 8192));
} else if (pending.length < newLen) {
const grow = Buffer.allocUnsafe(Math.max(newLen, pending.length * 2));
pending.copy(grow, 0, 0, pendingLen);
pending = grow;
}
buf.copy(pending, pendingLen, lineStart, got);
pendingLen = newLen;
}
}
}
if (pendingLen) {
let line = pending.toString("utf8", 0, pendingLen);
if (line.length && line.charCodeAt(line.length - 1) === 13) {
line = line.slice(0, -1);
}
if (line) this._consumeLine(line, state);
}
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
return this._finalizeState(state);
}
_initParseState() {
return {
tokensByModel: {},
compaction: null,
errors: [],
turnDurations: [],
thinkingBlockCount: 0,
usageExtras: {
service_tiers: new Set(),
speeds: new Set(),
inference_geos: new Set(),
},
// Track the model of the most recent assistant entry. JSONL is
// append-only and parsed in file order, so the last value seen here is
// the user's *current* model — used downstream to keep session.model in
// sync when the user invokes /model mid-session.
latestModel: null,
// Track the latest human-readable session title. Two sources, both
// append-only metadata lines: `custom-title` (explicit /rename, claude
// -n, picker Ctrl+R) and `ai-title` (auto-generated / plan-accept).
// Last value wins. Used downstream to keep session.name in sync in real
// time — custom titles take precedence over ai titles.
customTitle: null,
aiTitle: null,
// First real user prompt of the session (tool-result / meta / command
// entries skipped), whitespace-collapsed and length-capped. Used
// downstream as a fallback descriptor for placeholder-named sessions
// and their main agent — first value wins (it describes what the
// session set out to do), unlike the last-wins titles above.
firstUserMessage: null,
// Timestamps (ISO 8601, all from Claude Code's clock) used to recover a
// turn cancelled with no hook. `lastInterruptTs` is the most recent
// user-interrupt (Esc) entry; `lastTurnTs` is the most recent real turn
// activity (assistant output or a genuine user prompt). Comparing the
// two — both same-clock — tells us whether the transcript TAIL is an
// unrecovered interrupt. This holds even when Esc is pressed before any
// output (a sub-second interrupt), which a server-vs-transcript clock
// comparison cannot, since the UserPromptSubmit event is stamped later.
lastInterruptTs: null,
lastTurnTs: null,
};
}
_consumeLine(line, state) {
if (!line) return;
let entry;
try {
entry = JSON.parse(line);
} catch {
return;
}
// Session title metadata lines — sparse, no usage payload. Capture the
// latest value of each kind (append-only → last wins) and bail early.
if (entry.type === "custom-title") {
if (typeof entry.customTitle === "string" && entry.customTitle.trim()) {
state.customTitle = entry.customTitle;
}
return;
}
if (entry.type === "ai-title") {
if (typeof entry.aiTitle === "string" && entry.aiTitle.trim()) {
state.aiTitle = entry.aiTitle;
}
return;
}
// User-interrupt (Esc) marker. No hook fires for cancellation, so capture
// the timestamp here for the watchdog to move a stuck session back to
// waiting-for-input. The entry carries no usage/model, so return early.
if (
entry.type === "user" &&
(entry.interruptedMessageId != null || hasInterruptText(entry.message))
) {
if (entry.timestamp) state.lastInterruptTs = entry.timestamp;
return;
}
// Real turn activity — assistant output or a genuine (non-interrupt) user
// prompt. Tracking its latest timestamp lets _finalizeState decide whether
// a later interrupt was superseded by the user resuming (new prompt /
// model output) or is still the unrecovered tail of the transcript.
if ((entry.type === "assistant" || entry.type === "user") && entry.timestamp) {
if (!state.lastTurnTs || entry.timestamp > state.lastTurnTs)
state.lastTurnTs = entry.timestamp;
}
// First real user prompt — captured once (first wins; the file is parsed
// in order). extractFirstUserText filters out tool-result, meta, and
// slash-command plumbing entries so only human-typed text qualifies.
if (state.firstUserMessage === null && entry.type === "user") {
const firstText = extractFirstUserText(entry);
if (firstText) state.firstUserMessage = firstText;
}
if (entry.isCompactSummary) {
if (!state.compaction) state.compaction = { count: 0, entries: [] };
state.compaction.count++;
state.compaction.entries.push({
uuid: entry.uuid || null,
timestamp: entry.timestamp || null,
});
if (state.compaction.entries.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.compaction.entries);
}
}
if (entry.type === "system" && entry.subtype === "turn_duration" && entry.durationMs) {
const turnTs = entry.timestamp
? typeof entry.timestamp === "number"
? new Date(entry.timestamp).toISOString()
: entry.timestamp
: null;
state.turnDurations.push({ durationMs: entry.durationMs, timestamp: turnTs });
if (state.turnDurations.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.turnDurations);
}
}
const msg = entry.message || entry;
if (msg.type === "error" && msg.error) {
state.errors.push({
type: msg.error.type || "unknown_error",
message: msg.error.message || "Unknown API error",
timestamp: entry.timestamp || null,
});
if (state.errors.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.errors);
}
return;
}
if (entry.isApiErrorMessage) {
const errContent = Array.isArray(entry.message?.content) ? entry.message.content : [];
const errText = errContent[0]?.text ? errContent[0].text.slice(0, 500) : "Unknown error";
state.errors.push({
type: entry.error || "unknown_error",
message: errText,
timestamp: entry.timestamp || null,
});
if (state.errors.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.errors);
}
return;
}
const model = msg.model;
if (!model || model === "<synthetic>" || !msg.usage) return;
state.latestModel = model;
// Bucket tokens by the pricing dimensions (speed / geo / tier) so cost can
// apply fast-mode, data-residency, and Batch modifiers per bucket. The value
// carries those dimensions so the DB writer can key the row correctly.
const speed = normalizeSpeed(msg.usage);
const geo = normalizeGeo(msg.usage);
const tier = normalizeTier(msg.usage);
const key = bucketKey(model, speed, geo, tier);
if (!state.tokensByModel[key]) {
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
}
accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));
if (msg.usage.service_tier) state.usageExtras.service_tiers.add(msg.usage.service_tier);
if (msg.usage.speed) state.usageExtras.speeds.add(msg.usage.speed);
if (msg.usage.inference_geo && msg.usage.inference_geo !== "not_available") {
state.usageExtras.inference_geos.add(msg.usage.inference_geo);
}
const msgContent = msg.content || [];
if (Array.isArray(msgContent)) {
for (const block of msgContent) {
if (block.type === "thinking") state.thinkingBlockCount++;
}
}
}
_finalizeState(state) {
const hasTokens = Object.keys(state.tokensByModel).length > 0;
const hasErrors = state.errors.length > 0;
const hasTurnDurations = state.turnDurations.length > 0;
const hasUsageExtras =
state.usageExtras.service_tiers.size > 0 ||
state.usageExtras.speeds.size > 0 ||
state.usageExtras.inference_geos.size > 0;
if (
!hasTokens &&
!state.compaction &&
!hasErrors &&
!hasTurnDurations &&
!state.thinkingBlockCount &&
!hasUsageExtras &&
!state.latestModel &&
!state.customTitle &&
!state.aiTitle &&
!state.firstUserMessage &&
!state.lastInterruptTs &&
!state.lastTurnTs
) {
return null;
}
this._trimArray(state.errors);
this._trimArray(state.turnDurations);
if (state.compaction) this._trimArray(state.compaction.entries);
// usageExtras are accumulated as Sets and serialized as arrays here, with
// the same MAX_ARRAY_LEN tail cap applied via _capArrayFromSet.
const serializedExtras = hasUsageExtras
? {
service_tiers: this._capArrayFromSet(state.usageExtras.service_tiers),
speeds: this._capArrayFromSet(state.usageExtras.speeds),
inference_geos: this._capArrayFromSet(state.usageExtras.inference_geos),
}
: null;
return {
tokensByModel: hasTokens ? state.tokensByModel : null,
compaction: state.compaction,
errors: hasErrors ? state.errors : null,
turnDurations: hasTurnDurations ? state.turnDurations : null,
thinkingBlockCount: state.thinkingBlockCount,
usageExtras: serializedExtras,
latestModel: state.latestModel,
customTitle: state.customTitle,
aiTitle: state.aiTitle,
firstUserMessage: state.firstUserMessage,
lastInterruptTs: state.lastInterruptTs,
lastTurnTs: state.lastTurnTs,
pendingInterrupt: computePendingInterrupt(state.lastInterruptTs, state.lastTurnTs),
};
}
/**
* Parse an in-memory JSONL string. Retained for callers that already have
* the content as a string. Internal extraction paths now use _streamRange
* directly to avoid the V8 string-length limit on multi-hundred-MiB files.
*/
_parseContent(content) {
const state = this._initParseState();
let start = 0;
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) !== 10) continue;
let line = content.slice(start, i);
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
if (line) this._consumeLine(line, state);
start = i + 1;
}
if (start < content.length) {
let line = content.slice(start);
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
if (line) this._consumeLine(line, state);
}
return this._finalizeState(state);
}
_merge(cached, incremental) {
const tokensByModel = cached.result?.tokensByModel
? this._cloneTokens(cached.result.tokensByModel)
: {};
if (incremental && incremental.tokensByModel) {
for (const [key, tokens] of Object.entries(incremental.tokensByModel)) {
if (!tokensByModel[key]) {
tokensByModel[key] = emptyBucket(tokens.model, tokens.speed, tokens.geo, tokens.tier);
}
accumulateBucket(tokensByModel[key], tokens);
}
}
let compaction = cached.result?.compaction
? this._cloneCompaction(cached.result.compaction)
: null;
if (incremental && incremental.compaction) {
if (!compaction) compaction = { count: 0, entries: [] };
compaction.count += incremental.compaction.count;
compaction.entries.push(...incremental.compaction.entries);
this._trimArray(compaction.entries);
}
let errors = cached.result?.errors ? [...cached.result.errors] : null;
if (incremental && incremental.errors) {
if (!errors) errors = [];
errors.push(...incremental.errors);
this._trimArray(errors);
}
let turnDurations = cached.result?.turnDurations ? [...cached.result.turnDurations] : null;
if (incremental && incremental.turnDurations) {
if (!turnDurations) turnDurations = [];
turnDurations.push(...incremental.turnDurations);
this._trimArray(turnDurations);
}
const thinkingBlockCount =
(cached.result?.thinkingBlockCount || 0) + (incremental?.thinkingBlockCount || 0);
let usageExtras = cached.result?.usageExtras
? this._cloneUsageExtras(cached.result.usageExtras)
: null;
if (incremental && incremental.usageExtras) {
if (!usageExtras) {
usageExtras = { service_tiers: [], speeds: [], inference_geos: [] };
}
// Merge and deduplicate
const merged = {
service_tiers: new Set([
...usageExtras.service_tiers,
...incremental.usageExtras.service_tiers,
]),
speeds: new Set([...usageExtras.speeds, ...incremental.usageExtras.speeds]),
inference_geos: new Set([
...usageExtras.inference_geos,
...incremental.usageExtras.inference_geos,
]),
};
usageExtras = {
service_tiers: this._capArrayFromSet(merged.service_tiers),
speeds: this._capArrayFromSet(merged.speeds),
inference_geos: this._capArrayFromSet(merged.inference_geos),
};
}
// JSONL is append-only and parsed in order, so the incremental block's
// latestModel (when present) is the newest reading — fall back to the
// previously-cached value when the new chunk had no assistant entries.
const latestModel =
(incremental && incremental.latestModel) || cached.result?.latestModel || null;
// Same append-only logic for the session titles: the newest title line in
// the incremental chunk wins, else keep what was cached.
const customTitle =
(incremental && incremental.customTitle) || cached.result?.customTitle || null;
const aiTitle = (incremental && incremental.aiTitle) || cached.result?.aiTitle || null;
// First user message is first-wins (the opposite of the titles): the
// cached value was parsed from earlier in the file, so it stays; the
// incremental chunk only fills it when nothing was captured before.
const firstUserMessage =
cached.result?.firstUserMessage || (incremental && incremental.firstUserMessage) || null;
// Append-only: a newer interrupt / turn-activity timestamp in the
// incremental chunk supersedes the cached one, otherwise keep what was
// already known. pendingInterrupt is derived from the two by the caller.
const lastInterruptTs =
(incremental && incremental.lastInterruptTs) || cached.result?.lastInterruptTs || null;
const lastTurnTs = (incremental && incremental.lastTurnTs) || cached.result?.lastTurnTs || null;
return {
tokensByModel,
compaction,
errors,
turnDurations,
thinkingBlockCount,
usageExtras,
latestModel,
customTitle,
aiTitle,
firstUserMessage,
lastInterruptTs,
lastTurnTs,
};
}
_cloneTokens(tokensByModel) {
if (!tokensByModel) return null;
const clone = {};
for (const [model, t] of Object.entries(tokensByModel)) {
clone[model] = { ...t };
}
return clone;
}
_cloneCompaction(compaction) {
if (!compaction) return null;
return { count: compaction.count, entries: compaction.entries.map((e) => ({ ...e })) };
}
_cloneUsageExtras(extras) {
if (!extras) return null;
return {
service_tiers: [...(extras.service_tiers || [])],
speeds: [...(extras.speeds || [])],
inference_geos: [...(extras.inference_geos || [])],
};
}
/** Set cache entry with LRU eviction when at capacity */
_set(key, entry) {
// Delete first so re-insertion moves key to end of Map iteration order
this._cache.delete(key);
this._cache.set(key, entry);
// Evict oldest entries (first in Map iteration order) if over limit
while (this._cache.size > this._maxEntries) {
const oldest = this._cache.keys().next().value;
this._cache.delete(oldest);
}
}
/** Trim an array in-place to keep only the last `maxLen` items. No-op on falsy. */
_trimArray(arr, maxLen = MAX_ARRAY_LEN) {
if (!arr || !Array.isArray(arr) || arr.length <= maxLen) return;
arr.splice(0, arr.length - maxLen);
}
/** Convert Set to array with the same MAX_ARRAY_LEN tail cap. */
_capArrayFromSet(set) {
const arr = [...set];
this._trimArray(arr);
return arr;
}
/** Number of entries currently cached */
get size() {
return this._cache.size;
}
/** Remove a specific path from cache */
invalidate(transcriptPath) {
this._cache.delete(transcriptPath);
}
/** Clear all cached entries */
clear() {
this._cache.clear();
}
/** Return cache stats for diagnostics */
stats() {
const total = this._hits + this._misses;
return {
size: this._cache.size,
maxSize: this._maxEntries,
hits: this._hits,
misses: this._misses,
hitRate: total > 0 ? +((this._hits / total) * 100).toFixed(1) : 0,
keys: [...this._cache.keys()],
};
}
}
module.exports = TranscriptCache;
module.exports.extractFirstUserText = extractFirstUserText;
+256
View File
@@ -0,0 +1,256 @@
/**
* @file Detects whether the dashboard git checkout is behind the canonical
* remote default branch (origin/master or origin/main on a
* direct clone) after a non-destructive fetch. Branch- and fork-aware:
* picks the right remote, recognises feature-branch checkouts, and shapes
* manual_command so it actually closes the gap for the user's situation.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { execFile } = require("child_process");
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
// This build tracks its OWN repository only: origin points at
// git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor. "upstream" is
// deliberately absent from the priority list — a stray upstream remote must
// never make the update checker report commits from somebody else's repo.
const REMOTE_PRIORITY = ["origin"];
function execGit(cwd, args, opts = {}) {
const timeout = opts.timeout ?? 120_000;
return new Promise((resolve, reject) => {
execFile(
"git",
args,
{ cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8" },
(err, stdout) => {
if (err) reject(err);
else resolve(String(stdout).trim());
}
);
});
}
async function listRemotes(gitRoot) {
try {
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
} catch {
return [];
}
}
async function pickCanonicalRemote(gitRoot) {
const remotes = await listRemotes(gitRoot);
for (const candidate of REMOTE_PRIORITY) {
if (remotes.includes(candidate)) return candidate;
}
return remotes[0] || null;
}
async function resolveCompareRefForRemote(gitRoot, remote) {
const tryRefs = [`${remote}/master`, `${remote}/main`];
for (const ref of tryRefs) {
try {
await execGit(gitRoot, ["rev-parse", "--verify", ref], { timeout: 10_000 });
return ref;
} catch {
// continue
}
}
try {
const sym = await execGit(gitRoot, ["symbolic-ref", `refs/remotes/${remote}/HEAD`], {
timeout: 10_000,
});
const m = sym.match(/^refs\/remotes\/(.+)$/);
if (m) return m[1];
} catch {
// ignore
}
return null;
}
async function getCurrentBranch(gitRoot) {
try {
const branch = await execGit(gitRoot, ["symbolic-ref", "--short", "HEAD"], {
timeout: 10_000,
});
return branch || null;
} catch {
return null; // detached HEAD
}
}
async function getBranchUpstream(gitRoot) {
try {
return await execGit(gitRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
timeout: 10_000,
});
} catch {
return null; // no tracking branch configured
}
}
function stripRemotePrefix(ref) {
// "upstream/master" -> "master"; "origin/feature/foo" -> "feature/foo"
const idx = ref.indexOf("/");
return idx === -1 ? ref : ref.slice(idx + 1);
}
/**
* @param {string} [gitRoot]
* @param {{ skipFetch?: boolean }} [options]
* @returns {Promise<object>}
*/
async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
const root = path.resolve(gitRoot);
const gitDir = path.join(root, ".git");
if (!fs.existsSync(gitDir)) {
return {
git_repo: false,
update_available: false,
repo_root: root,
manual_command: null,
message: "Install directory is not a git clone; check for updates manually.",
};
}
const canonicalRemote = await pickCanonicalRemote(root);
if (!canonicalRemote) {
return {
git_repo: true,
update_available: false,
repo_root: root,
remote_ref: null,
local_sha: null,
remote_sha: null,
commits_behind: 0,
message: "No git remotes configured; automatic update check skipped.",
};
}
if (!options.skipFetch) {
try {
await execGit(root, ["fetch", canonicalRemote, "--prune"], { timeout: 120_000 });
} catch (err) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
fetch_error: err.message || String(err),
message: `Could not reach ${canonicalRemote}; try again when online.`,
};
}
}
const remoteRef = await resolveCompareRefForRemote(root, canonicalRemote);
if (!remoteRef) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
message: `Could not resolve ${canonicalRemote}/master, ${canonicalRemote}/main, or ${canonicalRemote}/HEAD.`,
};
}
const currentBranch = await getCurrentBranch(root);
const branchUpstream = await getBranchUpstream(root);
const tracksCanonical = branchUpstream === remoteRef;
let localSha;
let remoteSha;
let commitsBehind = 0;
try {
localSha = await execGit(root, ["rev-parse", "HEAD"], { timeout: 10_000 });
remoteSha = await execGit(root, ["rev-parse", remoteRef], { timeout: 10_000 });
const countStr = await execGit(root, ["rev-list", "--count", `HEAD..${remoteRef}`], {
timeout: 30_000,
});
commitsBehind = Number.parseInt(countStr, 10);
if (Number.isNaN(commitsBehind)) commitsBehind = 0;
} catch (err) {
return {
git_repo: true,
update_available: false,
repo_root: root,
canonical_remote: canonicalRemote,
remote_ref: remoteRef,
message: err.message || String(err),
};
}
const updateAvailable = commitsBehind > 0;
const isProd = process.env.NODE_ENV === "production";
const installSteps = ["npm run setup"];
if (isProd) installSteps.push("npm run build");
// Branch-aware manual_command. Three situations:
// 1. tracksCanonical: HEAD's tracked upstream IS the canonical ref. A
// plain `git pull --ff-only` does the right thing — typical clone on
// the default branch.
// 2. Same branch *name* as canonical but different upstream (the fork
// case: local master tracking origin/master, canonical is
// upstream/master). Need to fetch the canonical remote and merge it
// into the local branch.
// 3. Anything else (feature branch, detached HEAD): pulling the current
// branch wouldn't bring in canonical commits, so don't suggest it.
// Offer a fetch and let the user decide how to integrate.
const canonicalBranchName = stripRemotePrefix(remoteRef);
let manualParts;
let situationNote;
let situation;
if (tracksCanonical) {
situation = "tracking_canonical";
manualParts = [`cd "${root}"`, "git pull --ff-only", ...installSteps];
situationNote = null;
} else if (currentBranch && currentBranch === canonicalBranchName) {
situation = "fork_or_diverged_tracking";
manualParts = [
`cd "${root}"`,
`git fetch ${canonicalRemote}`,
`git merge --ff-only ${remoteRef}`,
...installSteps,
];
situationNote = `You're on '${currentBranch}' tracking '${
branchUpstream || "no upstream"
}'. This command fast-forwards your branch from ${remoteRef} (the canonical default).`;
} else {
situation = currentBranch ? "feature_branch" : "detached_head";
manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote}`];
situationNote = currentBranch
? `You're on '${currentBranch}', not the canonical default branch (${remoteRef}). Fetched commits won't be pulled into your branch — rebase or merge ${remoteRef} when you're ready.`
: `HEAD is detached. Fetched commits stay under ${remoteRef}; check out the canonical default branch when ready.`;
}
const manualCommand = manualParts.join(" && ");
return {
git_repo: true,
update_available: updateAvailable,
repo_root: root,
remote_ref: remoteRef,
canonical_remote: canonicalRemote,
current_branch: currentBranch,
tracking_upstream: branchUpstream,
tracks_canonical: tracksCanonical,
situation,
local_sha: localSha,
remote_sha: remoteSha,
commits_behind: commitsBehind,
manual_command: manualCommand,
situation_note: situationNote,
message: updateAvailable
? `${commitsBehind} commit(s) on ${remoteRef} not in your checkout.`
: "Your checkout includes the tip of the canonical default branch.",
};
}
module.exports = { getUpdatesStatus, DEFAULT_ROOT };
+501
View File
@@ -0,0 +1,501 @@
/**
* @file Webhook provider registry. Each provider is described declaratively —
* its display label, "family" (which determines optional HMAC/custom-header
* support), the credential fields it needs, how its outbound URL is resolved,
* any auth headers, and a payload formatter that turns a fired alert into that
* provider's native request body. server/lib/webhooks.js consumes this registry
* to build and deliver requests; routes/webhooks.js uses it for validation and
* for the redacted provider metadata exposed to the UI.
*
* Adding a provider = one entry here (+ a formatter). No delivery/route changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// ── Shared helpers ──────────────────────────────────────────────────────────
function truncate(value, max) {
const s = String(value == null ? "" : value);
return s.length > max ? `${s.slice(0, max - 1)}` : s;
}
function escHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function parseDetails(alert) {
if (alert.details == null) return null;
if (typeof alert.details === "object") return alert.details;
try {
return JSON.parse(alert.details);
} catch {
return alert.details;
}
}
// [{ title, value, short }] for the chat platforms that use Slack-style
// attachment fields (Slack legacy, Mattermost, Rocket.Chat).
function attachmentFields(alert) {
const fields = [{ title: "Type", value: alert.rule_type, short: true }];
if (alert.session_id)
fields.push({ title: "Session", value: truncate(alert.session_id, 120), short: true });
if (alert.agent_id)
fields.push({ title: "Agent", value: truncate(alert.agent_id, 120), short: true });
return fields;
}
const ACCENT_HEX = "#EF4444";
const ACCENT_INT = 0xef4444;
// ── Formatters ──────────────────────────────────────────────────────────────
// Slack incoming webhook — Block Kit. `text` is the required fallback string.
function formatSlack(alert) {
const ctx = [`Type: \`${alert.rule_type}\``];
if (alert.session_id) ctx.push(`Session: \`${truncate(alert.session_id, 64)}\``);
if (alert.agent_id) ctx.push(`Agent: \`${truncate(alert.agent_id, 64)}\``);
ctx.push(alert.triggered_at);
return {
text: truncate(`🔔 ${alert.rule_name}: ${alert.message}`, 3000),
blocks: [
{
type: "header",
text: { type: "plain_text", text: truncate(`🔔 ${alert.rule_name}`, 150), emoji: true },
},
{ type: "section", text: { type: "mrkdwn", text: truncate(alert.message, 2900) } },
{ type: "context", elements: [{ type: "mrkdwn", text: truncate(ctx.join(" • "), 1900) }] },
],
};
}
// Discord webhook — a single rich embed.
function formatDiscord(alert) {
const fields = [{ name: "Type", value: truncate(alert.rule_type, 1024), inline: true }];
if (alert.session_id)
fields.push({ name: "Session", value: truncate(alert.session_id, 1024), inline: true });
if (alert.agent_id)
fields.push({ name: "Agent", value: truncate(alert.agent_id, 1024), inline: true });
return {
username: "Claude Code Monitor",
embeds: [
{
title: truncate(`🔔 ${alert.rule_name}`, 256),
description: truncate(alert.message, 4000),
color: ACCENT_INT,
fields,
footer: { text: "Claude Code Agent Monitor" },
timestamp: alert.triggered_at,
},
],
};
}
// Microsoft Teams — Adaptive Card delivered via a Power Automate "Workflows"
// webhook. The legacy O365 Connector + MessageCard transport was retired
// (connectors progressively disabled May 1822 2026), so the target URL is a
// Workflows "When a Teams webhook request is received" URL and the body is the
// {type:"message", attachments:[adaptive card]} envelope that flow expects.
function formatTeams(alert) {
const facts = [{ title: "Type", value: alert.rule_type }];
if (alert.session_id) facts.push({ title: "Session", value: truncate(alert.session_id, 256) });
if (alert.agent_id) facts.push({ title: "Agent", value: truncate(alert.agent_id, 256) });
facts.push({ title: "Triggered", value: alert.triggered_at });
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
contentUrl: null,
content: {
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
size: "Large",
weight: "Bolder",
color: "Attention",
text: truncate(`🔔 ${alert.rule_name}`, 500),
wrap: true,
},
{ type: "TextBlock", text: truncate(alert.message, 4000), wrap: true },
{ type: "FactSet", facts },
],
},
},
],
};
}
// Google Chat incoming webhook — simple text message with basic markdown
// (*bold*, `code`). Reliable across spaces without card-schema pitfalls.
function formatGoogleChat(alert) {
const lines = [`🔔 *${alert.rule_name}*`, alert.message, ""];
const meta = [`\`${alert.rule_type}\``];
if (alert.session_id) meta.push(`session \`${truncate(alert.session_id, 64)}\``);
if (alert.agent_id) meta.push(`agent \`${truncate(alert.agent_id, 64)}\``);
lines.push(meta.join(" · "));
return { text: truncate(lines.join("\n"), 4000) };
}
// Mattermost incoming webhook — Slack-compatible (legacy attachments).
function formatMattermost(alert) {
return {
username: "Claude Code Monitor",
text: `🔔 **${alert.rule_name}**`,
attachments: [
{
fallback: truncate(`${alert.rule_name}: ${alert.message}`, 1000),
color: ACCENT_HEX,
text: truncate(alert.message, 3000),
fields: attachmentFields(alert),
footer: "Claude Code Agent Monitor",
},
],
};
}
// Rocket.Chat incoming webhook — text + Slack-style attachments.
function formatRocketChat(alert) {
return {
alias: "Claude Code Monitor",
text: `🔔 *${alert.rule_name}*`,
attachments: [
{
title: truncate(alert.rule_name, 256),
text: truncate(alert.message, 3000),
color: ACCENT_HEX,
fields: attachmentFields(alert),
},
],
};
}
// Telegram Bot API sendMessage. chat_id comes from config; the bot token is in
// the resolved URL. HTML parse mode, so message text is HTML-escaped.
function formatTelegram(alert, config) {
const lines = [`🔔 <b>${escHtml(alert.rule_name)}</b>`, escHtml(alert.message)];
const meta = [`<code>${escHtml(alert.rule_type)}</code>`];
if (alert.session_id)
meta.push(`session <code>${escHtml(truncate(alert.session_id, 64))}</code>`);
lines.push("", meta.join(" · "));
return {
chat_id: config.chat_id,
parse_mode: "HTML",
disable_web_page_preview: true,
text: truncate(lines.join("\n"), 4096),
};
}
// PagerDuty Events API v2 (trigger). routing_key + severity from config.
// dedup_key groups repeat firings of the same rule+session into one incident.
function formatPagerDuty(alert, config) {
return {
routing_key: config.routing_key,
event_action: "trigger",
dedup_key: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
payload: {
summary: truncate(`${alert.rule_name}: ${alert.message}`, 1024),
source: alert.session_id || "claude-code-agent-monitor",
severity: config.severity || "warning",
custom_details: {
rule_name: alert.rule_name,
rule_type: alert.rule_type,
session_id: alert.session_id || null,
agent_id: alert.agent_id || null,
message: alert.message,
details: parseDetails(alert),
triggered_at: alert.triggered_at,
},
},
};
}
// Opsgenie Alert API. api_key is sent as the GenieKey auth header (see
// authFrom), not in the body. alias dedups; region selects the host.
function formatOpsgenie(alert) {
return {
message: truncate(`${alert.rule_name}: ${alert.message}`, 130),
alias: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
description: truncate(alert.message, 15000),
source: "claude-code-agent-monitor",
tags: ["claude-code", alert.rule_type].filter(Boolean),
details: {
rule_name: String(alert.rule_name),
rule_type: String(alert.rule_type),
session_id: alert.session_id ? String(alert.session_id) : "",
agent_id: alert.agent_id ? String(alert.agent_id) : "",
triggered_at: String(alert.triggered_at),
},
};
}
// Splunk On-Call (VictorOps) generic REST endpoint. The API + routing key live
// in the user-pasted URL; severity maps to message_type.
function formatSplunkOnCall(alert, config) {
return {
message_type: config.severity || "WARNING",
entity_id: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
entity_display_name: truncate(alert.rule_name, 256),
state_message: truncate(
`${alert.message}\n\ntype: ${alert.rule_type}${alert.session_id ? `\nsession: ${alert.session_id}` : ""}`,
20000
),
monitoring_tool: "claude-code-agent-monitor",
};
}
// Generic / automation platforms (Zapier, Make, n8n, Pipedream) — a clean,
// stable JSON envelope. Optional HMAC signing + custom headers handled by the
// caller (server/lib/webhooks.js) for the whole generic family.
function formatGeneric(alert) {
return {
event: "alert.triggered",
source: "claude-code-agent-monitor",
sent_at: new Date().toISOString(),
alert: {
id: alert.id ?? null,
rule_id: alert.rule_id ?? null,
rule_name: alert.rule_name,
rule_type: alert.rule_type,
session_id: alert.session_id ?? null,
agent_id: alert.agent_id ?? null,
message: alert.message,
details: parseDetails(alert),
triggered_at: alert.triggered_at,
},
};
}
// ── Registry ────────────────────────────────────────────────────────────────
//
// family:
// "chat" — incoming-webhook chat platforms (no extra auth, https URL)
// "api" — alert/event APIs with credentials and/or derived URLs
// "generic" — arbitrary-JSON endpoints; support optional HMAC + custom headers
//
// needsUrl — the user must supply the outbound URL
// https — enforce https on a user-supplied URL (false allows http for local)
// defaultUrl — fallback URL when the user supplies none
// urlFrom(cfg)— derive the URL from config (user supplies no URL)
// authFrom(cfg)— derive auth request headers from config
// fields — provider config fields (rendered by the UI, validated server-side)
const PROVIDERS = {
slack: { label: "Slack", family: "chat", needsUrl: true, https: true, format: formatSlack },
discord: { label: "Discord", family: "chat", needsUrl: true, https: true, format: formatDiscord },
teams: {
label: "Microsoft Teams",
family: "chat",
needsUrl: true,
https: true,
urlHint:
"Power Automate Workflows URL (Teams → Workflows → 'Post to a channel when a webhook request is received')",
format: formatTeams,
},
google_chat: {
label: "Google Chat",
family: "chat",
needsUrl: true,
https: true,
format: formatGoogleChat,
},
mattermost: {
label: "Mattermost",
family: "chat",
needsUrl: true,
https: true,
format: formatMattermost,
},
rocketchat: {
label: "Rocket.Chat",
family: "chat",
needsUrl: true,
https: true,
format: formatRocketChat,
},
telegram: {
label: "Telegram",
family: "api",
https: true,
fields: [
{ key: "bot_token", label: "Bot token", secret: true, required: true },
{ key: "chat_id", label: "Chat ID", required: true },
],
urlFrom: (c) => (c.bot_token ? `https://api.telegram.org/bot${c.bot_token}/sendMessage` : null),
format: formatTelegram,
},
pagerduty: {
label: "PagerDuty",
family: "api",
https: true,
defaultUrl: "https://events.pagerduty.com/v2/enqueue",
fields: [
{ key: "routing_key", label: "Integration (routing) key", secret: true, required: true },
{
key: "severity",
label: "Severity",
type: "enum",
options: ["info", "warning", "error", "critical"],
default: "warning",
},
],
format: formatPagerDuty,
},
opsgenie: {
label: "Opsgenie",
family: "api",
https: true,
fields: [
{ key: "api_key", label: "API key", secret: true, required: true },
{ key: "region", label: "Region", type: "enum", options: ["us", "eu"], default: "us" },
],
urlFrom: (c) =>
c.region === "eu"
? "https://api.eu.opsgenie.com/v2/alerts"
: "https://api.opsgenie.com/v2/alerts",
authFrom: (c) => (c.api_key ? { Authorization: `GenieKey ${c.api_key}` } : {}),
format: formatOpsgenie,
},
splunk_oncall: {
label: "Splunk On-Call",
family: "api",
needsUrl: true,
https: true,
urlHint: "VictorOps REST endpoint URL (contains your API + routing key)",
fields: [
{
key: "severity",
label: "Message type",
type: "enum",
options: ["CRITICAL", "WARNING", "INFO"],
default: "WARNING",
},
],
format: formatSplunkOnCall,
// VictorOps returns HTTP 200 even when it rejects the event — the real
// outcome is in the body ({ result: "success" | "failure" }). Inspect it so
// a logical failure isn't silently recorded as delivered.
verifyResponse: (text) => {
if (!text) return { ok: true };
try {
const j = JSON.parse(text);
if (j && typeof j.result === "string" && j.result.toLowerCase() === "failure") {
return { ok: false, error: j.message || "Splunk On-Call reported failure" };
}
} catch {
/* non-JSON 200 body — trust the status */
}
return { ok: true };
},
},
zapier: {
label: "Zapier",
family: "generic",
needsUrl: true,
https: true,
format: formatGeneric,
},
make: { label: "Make", family: "generic", needsUrl: true, https: true, format: formatGeneric },
n8n: { label: "n8n", family: "generic", needsUrl: true, https: false, format: formatGeneric },
pipedream: {
label: "Pipedream",
family: "generic",
needsUrl: true,
https: true,
format: formatGeneric,
},
generic: {
label: "Generic (custom JSON)",
family: "generic",
needsUrl: true,
https: false,
format: formatGeneric,
},
};
const WEBHOOK_TYPES = Object.keys(PROVIDERS);
function isGenericFamily(type) {
return PROVIDERS[type]?.family === "generic";
}
/** Resolve the outbound URL for a target: derived → user-supplied → default. */
function resolveUrl(target) {
const p = PROVIDERS[target.type];
if (!p) return target.url || null;
if (p.urlFrom) {
const derived = p.urlFrom(target.config || {});
if (derived) return derived;
}
if (target.url) return target.url;
return p.defaultUrl || null;
}
/** Provider-derived auth headers (e.g. Opsgenie GenieKey). */
function resolveAuthHeaders(target) {
const p = PROVIDERS[target.type];
if (p?.authFrom) return p.authFrom(target.config || {}) || {};
return {};
}
function formatPayload(type, alert, config = {}) {
const p = PROVIDERS[type] || PROVIDERS.generic;
return p.format(alert, config);
}
/** Whether a user-supplied URL is required for this provider type. */
function urlRequired(type) {
const p = PROVIDERS[type];
if (!p) return true;
if (p.urlFrom || p.defaultUrl) return false;
return !!p.needsUrl;
}
/** Redacted, serializable provider metadata for the UI/API. */
function publicProviders() {
return WEBHOOK_TYPES.map((type) => {
const p = PROVIDERS[type];
return {
type,
label: p.label,
family: p.family,
url_required: urlRequired(type),
has_default_url: !!p.defaultUrl,
derives_url: !!p.urlFrom,
allow_http: p.https === false,
url_hint: p.urlHint || null,
supports_secret: p.family === "generic",
supports_headers: p.family === "generic",
fields: (p.fields || []).map((f) => ({
key: f.key,
label: f.label,
secret: !!f.secret,
required: !!f.required,
type: f.type || "string",
options: f.options || null,
default: f.default ?? null,
})),
};
});
}
module.exports = {
PROVIDERS,
WEBHOOK_TYPES,
isGenericFamily,
resolveUrl,
resolveAuthHeaders,
formatPayload,
urlRequired,
publicProviders,
truncate,
};
+309
View File
@@ -0,0 +1,309 @@
/**
* @file Universal webhook delivery for fired alerts. A "target" is an outbound
* destination described by the provider registry (server/lib/webhook-providers.js)
* — Slack, Discord, Teams, Mattermost, Rocket.Chat, Telegram, PagerDuty,
* Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream, or a generic endpoint.
* When the alerting engine fires an alert (server/lib/alerts.js), it calls
* dispatchAlert(), which formats the provider-native payload and POSTs it to
* every enabled target (optionally scoped to specific rules) with a timeout and
* bounded retry/backoff. Every attempt-chain is recorded in webhook_deliveries.
*
* Delivery is detached and fully fail-safe: it never throws into, slows, or
* blocks the alert path or hook ingestion.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("crypto");
const { stmts } = require("../db");
const {
PROVIDERS,
WEBHOOK_TYPES,
isGenericFamily,
resolveUrl,
resolveAuthHeaders,
formatPayload,
truncate,
} = require("./webhook-providers");
// Tunables (env-overridable so tests can shrink timeouts/backoff). All read at
// module load — restart to change.
function posEnv(name, fallback) {
const raw = parseInt(process.env[name], 10);
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
}
const TIMEOUT_MS = posEnv("WEBHOOK_TIMEOUT_MS", 10_000);
const MAX_ATTEMPTS = posEnv("WEBHOOK_MAX_ATTEMPTS", 3);
const RETRY_BASE_MS = posEnv("WEBHOOK_RETRY_BASE_MS", 1500);
// Enabled-target cache. Alert fires are hot; targets only change through the
// CRUD routes, which call invalidateWebhookCache().
let targetsCache = null;
function invalidateWebhookCache() {
targetsCache = null;
}
/** Parse the JSON columns and coerce the enabled flag for a raw target row. */
function normalizeTarget(row) {
if (!row) return null;
let headers = null;
let ruleIds = null;
let config = null;
try {
headers = row.headers ? JSON.parse(row.headers) : null;
} catch {
/* tolerate hand-edited bad JSON — extra headers simply not applied */
}
try {
ruleIds = row.rule_ids ? JSON.parse(row.rule_ids) : null;
} catch {
/* tolerate bad JSON — target falls back to "all rules" */
}
try {
config = row.config ? JSON.parse(row.config) : null;
} catch {
/* tolerate bad JSON — provider config falls back to empty */
}
return { ...row, enabled: row.enabled === 1, headers, rule_ids: ruleIds, config };
}
function loadEnabledTargets() {
if (targetsCache) return targetsCache;
targetsCache = stmts.listEnabledWebhookTargets.all().map(normalizeTarget);
return targetsCache;
}
/**
* Build the HTTP request for a target + alert: resolved URL, provider-native
* serialized body, and headers (provider auth headers, plus custom headers and
* an optional HMAC-SHA256 signature for the generic family). Exported for tests.
*/
function buildRequest(target, alert) {
const url = resolveUrl(target);
if (!url) throw new Error(`no URL resolved for webhook type "${target.type}"`);
const payload = formatPayload(target.type, alert, target.config || {});
const body = JSON.stringify(payload);
const headers = {
"Content-Type": "application/json",
"User-Agent": "claude-code-agent-monitor/webhooks",
...resolveAuthHeaders(target),
};
if (isGenericFamily(target.type)) {
if (target.headers && typeof target.headers === "object") {
for (const [k, v] of Object.entries(target.headers)) {
if (typeof k !== "string" || typeof v !== "string") continue;
// Never let a custom header clobber Content-Type or the signature.
const lower = k.toLowerCase();
if (lower === "content-type" || lower === "x-webhook-signature") continue;
headers[k] = v;
}
}
if (target.secret) {
const ts = new Date().toISOString();
const sig = crypto.createHmac("sha256", target.secret).update(`${ts}.${body}`).digest("hex");
headers["X-Webhook-Timestamp"] = ts;
headers["X-Webhook-Signature"] = `sha256=${sig}`;
}
}
return { url, body, headers };
}
// ── Delivery ────────────────────────────────────────────────────────────────
function sleep(ms) {
return new Promise((resolve) => {
const t = setTimeout(resolve, ms);
if (t.unref) t.unref();
});
}
async function postOnce(url, body, headers) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
if (timer.unref) timer.unref();
try {
const res = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
redirect: "follow",
});
// Read the response body — some providers (Splunk On-Call) signal failure
// in the body despite a 200, so deliver() may need to inspect it. Also
// frees the socket promptly. (Named distinctly from the `body` param.)
let responseBody = "";
try {
responseBody = await res.text();
} catch {
/* body read is best-effort */
}
return {
ok: res.status >= 200 && res.status < 300,
status: res.status,
error: null,
body: responseBody,
};
} catch (err) {
const timedOut = err?.name === "AbortError";
return {
ok: false,
status: null,
error: timedOut ? "timeout" : err?.message || "network error",
body: "",
};
} finally {
clearTimeout(timer);
}
}
function recordDelivery(target, alertId, { status, statusCode, attempts, error }) {
try {
stmts.insertWebhookDelivery.run(
target.id,
target.name,
target.type,
alertId == null ? null : alertId,
status,
statusCode == null ? null : statusCode,
attempts,
error == null ? null : truncate(error, 500)
);
stmts.pruneWebhookDeliveries.run();
} catch (err) {
console.warn("[WEBHOOK] delivery log write failed:", err?.message || err);
}
}
/**
* Deliver one alert to one target with bounded retry. Retries on transport
* errors, HTTP 429, and 5xx; gives up immediately on other 4xx (misconfigured
* URL / bad payload won't fix themselves). Always records the outcome and
* never throws. Returns `{ ok, status, attempts, error }`.
*/
async function deliver(target, alert) {
let built;
try {
built = buildRequest(target, alert);
} catch (err) {
recordDelivery(target, alert.id, {
status: "failed",
statusCode: null,
attempts: 0,
error: `request build failed: ${err?.message || err}`,
});
return { ok: false, status: null, attempts: 0, error: "request build failed" };
}
let attempts = 0;
let status = null;
let error = null;
const verifyResponse = PROVIDERS[target.type]?.verifyResponse;
while (attempts < MAX_ATTEMPTS) {
attempts += 1;
const res = await postOnce(built.url, built.body, built.headers);
status = res.status;
error = res.error;
if (res.ok) {
// Some providers (Splunk On-Call) return 200 even on rejection — let the
// provider veto a "successful" status by inspecting the response body.
const verdict = verifyResponse ? verifyResponse(res.body) : { ok: true };
if (verdict.ok) {
recordDelivery(target, alert.id, {
status: "success",
statusCode: status,
attempts,
error: null,
});
return { ok: true, status, attempts };
}
// A logical rejection won't fix on retry — fail immediately.
error = verdict.error || "provider reported failure";
break;
}
const retryable = status == null || status === 429 || status >= 500;
if (!retryable || attempts >= MAX_ATTEMPTS) break;
await sleep(RETRY_BASE_MS * attempts);
}
recordDelivery(target, alert.id, {
status: "failed",
statusCode: status,
attempts,
error: error || (status ? `HTTP ${status}` : "request failed"),
});
return {
ok: false,
status,
attempts,
error: error || (status ? `HTTP ${status}` : "request failed"),
};
}
/** A target receives an alert when it has no rule scope, or the alert's rule is in scope. */
function targetAppliesTo(target, alert) {
if (!Array.isArray(target.rule_ids) || target.rule_ids.length === 0) return true;
return target.rule_ids.includes(alert.rule_id);
}
/**
* Fan an alert out to every enabled, in-scope target. Returns a promise that
* settles when all deliveries finish (used by tests); callers in the alert
* path invoke it fire-and-forget. Never rejects.
*/
function dispatchAlert(alert) {
let targets;
try {
targets = loadEnabledTargets();
} catch (err) {
console.warn("[WEBHOOK] target load failed:", err?.message || err);
return Promise.resolve([]);
}
const applicable = targets.filter((t) => {
try {
return targetAppliesTo(t, alert);
} catch {
return false;
}
});
if (applicable.length === 0) return Promise.resolve([]);
return Promise.allSettled(applicable.map((t) => deliver(t, alert)));
}
/**
* Send a synthetic test alert to a single (already DB-loaded, un-redacted)
* target. Awaits the result so the route can report success/failure inline.
*/
function sendTest(target) {
const alert = {
id: null,
rule_id: null,
rule_name: "Webhook test",
rule_type: "test",
session_id: null,
agent_id: null,
message: `Test notification from Claude Code Agent Monitor to "${target.name}". If you can read this, delivery works.`,
details: { test: true, target: target.name, type: target.type },
triggered_at: new Date().toISOString(),
};
return deliver(target, alert);
}
module.exports = {
PROVIDERS,
WEBHOOK_TYPES,
invalidateWebhookCache,
loadEnabledTargets,
normalizeTarget,
formatPayload,
buildRequest,
deliver,
dispatchAlert,
sendTest,
targetAppliesTo,
};
+807
View File
@@ -0,0 +1,807 @@
/**
* Workflow-tool run ingestion.
*
* The Claude Code "Workflow" tool (and self-paced /loop) spawn fleets of inner
* sub-agents that emit NO hooks — so hook-based ingestion can never see them.
* Everything lives on disk under the launching session's transcript folder:
*
* <projects>/<enc-cwd>/<sessionId>/
* workflows/
* scripts/<name>-wf_<runId>.js ← written at LAUNCH
* wf_<runId>.json ← run journal, written at COMPLETION
* subagents/workflows/<runId>/
* agent-<agentId>.jsonl ← one transcript per inner agent
* agent-<agentId>.meta.json
*
* The run journal is the source of truth for a completed run: identity,
* lifecycle, aggregates (agentCount/totalTokens/totalToolCalls), phases[], and
* workflowProgress[] — a MIXED log of `type:"workflow_phase"` markers and
* `type:"workflow_agent"` entries. Each workflow_agent entry carries agentId,
* state ("done"/"error"/…), label, phaseTitle, tokens, toolCalls, durationMs,
* etc., and its agentId is the EXACT agent-<agentId>.jsonl basename in the
* per-run nested dir above. Because the journal is terminal-only, a running
* workflow is detected from its launch script and replaced by the journal
* record on completion (idempotent upsert by run_id).
*
* Inner agents are linked into the existing agents table via the same
* `${sessionId}-jsonl-<agentId>` id scheme that importSubagentFromJsonl uses,
* so ingestion CONVERGES with any prior subagent import (no duplicate rows).
* Per-agent token/tool/duration metrics come from the journal's progress[]
* JSON — this module never writes token_usage, so it cannot double-count.
*
* All functions are fail-safe: a malformed/partial journal throws only locally
* and is skipped; ingestion never blocks or breaks hook handling.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
// Lazy-required to avoid a require cycle (import-history → db → … ) and to keep
// startup cheap; mirrors how server/index.js lazy-requires import helpers.
function importHistory() {
return require("../../scripts/import-history");
}
let claudeHome = null;
function getClaudeHomeLib() {
if (!claudeHome) claudeHome = require("./claude-home");
return claudeHome;
}
/**
* Canonical run id derived from a journal/script filename. Both
* `wf_<runId>.json` and `<name>-wf_<runId>.js` reduce to the same `wf_<runId>`
* token so a launch-detected "running" row and its later journal reconcile on
* the same key.
*/
function extractRunId(filename) {
const base = path.basename(filename).replace(/\.(json|js)$/i, "");
const m = base.match(/wf_[A-Za-z0-9_-]+$/);
return m ? m[0] : base;
}
/** Workflow name from a launch-script basename: strip the `-wf_<runId>` tail. */
function nameFromScript(filename) {
const base = path.basename(filename).replace(/\.js$/i, "");
return base.replace(/-?wf_[A-Za-z0-9_-]+$/, "") || base;
}
function toIso(value) {
if (value == null) return null;
if (typeof value === "number") {
try {
return new Date(value).toISOString();
} catch {
return null;
}
}
return String(value);
}
/** Map a journal progress `state` to an agents.status value. */
function mapState(state) {
switch (String(state || "").toLowerCase()) {
case "error":
case "failed":
return "error";
case "running":
case "working":
case "active":
case "in_progress":
case "queued":
return "working";
case "done":
case "completed":
case "success":
return "completed";
default:
return "completed";
}
}
// Token fields carried on a parsed-subagent bucket (camelCase, matching
// writeSessionTokens). Used to fold inner-agent usage into the session's cost.
const TOKEN_FIELDS = [
"input",
"output",
"cacheRead",
"cacheWrite",
"cacheWrite1h",
"webSearch",
"webFetch",
"codeExec",
];
/**
* Merge a parsed agent's tokensByModel into a session-level accumulator, keyed
* by (model, speed, geo) with the service_tier forced to "workflow". This
* namespaces workflow spend into its own token_usage bucket so it never
* collides with — or clobbers — the main-transcript writer's rows, while still
* being summed per-model by the cost calculator. Inner agents are sidechain
* contexts whose usage is NOT in the parent transcript, so this is additive,
* not double-counting (same model as combineSessionTokens for subagents).
*/
function mergeWorkflowTokens(dst, src) {
for (const b of Object.values(src || {})) {
if (!b || !b.model) continue;
const key = `${b.model}|${b.speed}|${b.geo}|workflow`;
if (!dst[key]) {
dst[key] = {
model: b.model,
speed: b.speed,
geo: b.geo,
tier: "workflow",
};
for (const f of TOKEN_FIELDS) dst[key][f] = 0;
}
for (const f of TOKEN_FIELDS) dst[key][f] += b[f] || 0;
}
}
/**
* Resolve a session's transcript JSONL path from a session-like row. Prefers an
* explicit transcript_path; otherwise derives it from (id, cwd) via claude-home.
*/
function resolveTranscriptPath(session) {
if (session && session.transcript_path) return session.transcript_path;
if (session && session.id && session.cwd) {
try {
return getClaudeHomeLib().getTranscriptPath(session.id, session.cwd);
} catch {
return null;
}
}
return null;
}
/**
* Locate a session's workflow artifacts from its transcript JSONL path.
* Workflows live at `<dir>/<sessionId>/workflows/` next to
* `<dir>/<sessionId>.jsonl`; inner-agent transcripts are resolved per-run via
* agentsDirForRun(sessionDir, runId).
*
* @returns {{ sessionDir: string|null, workflowsDir: string|null,
* journals: string[], scripts: string[] }}
*/
function findSessionWorkflows(transcriptPath) {
const empty = {
sessionDir: null,
workflowsDir: null,
journals: [],
scripts: [],
liveRuns: [],
};
if (!transcriptPath) return empty;
const dir = path.dirname(transcriptPath);
const sessionId = path.basename(transcriptPath, ".jsonl");
const sessionDir = path.join(dir, sessionId);
const workflowsDir = path.join(sessionDir, "workflows");
const journals = [];
const scripts = [];
try {
if (fs.existsSync(workflowsDir)) {
for (const f of fs.readdirSync(workflowsDir)) {
if (f.startsWith("wf_") && f.endsWith(".json")) journals.push(path.join(workflowsDir, f));
}
const scriptsDir = path.join(workflowsDir, "scripts");
if (fs.existsSync(scriptsDir)) {
for (const f of fs.readdirSync(scriptsDir)) {
if (f.endsWith(".js")) scripts.push(path.join(scriptsDir, f));
}
}
}
} catch {
/* non-fatal — partial dir during a live run */
}
// Live per-run dirs: <sessionDir>/subagents/workflows/<runId>/ — present while
// a workflow is still running (journal.jsonl + growing agent-*.jsonl), before
// the terminal wf_<runId>.json journal is written.
const liveRuns = [];
try {
const base = path.join(sessionDir, "subagents", "workflows");
if (fs.existsSync(base)) {
for (const d of fs.readdirSync(base, { withFileTypes: true })) {
if (d.isDirectory()) liveRuns.push({ runId: d.name, dir: path.join(base, d.name) });
}
}
} catch {
/* non-fatal */
}
return { sessionDir, workflowsDir, journals, scripts, liveRuns };
}
/**
* Per-run inner-agent transcript directory. The Workflow tool writes each
* fleet's agents under `<sessionId>/subagents/workflows/<runId>/agent-*.jsonl`
* (NOT the session's top-level subagents/ dir).
*/
function agentsDirForRun(sessionDir, runId) {
return path.join(sessionDir, "subagents", "workflows", runId);
}
/** Read + normalize a run journal file. Returns null on any parse failure. */
function parseWorkflowJournal(journalPath) {
let raw;
try {
raw = fs.readFileSync(journalPath, "utf8");
} catch {
return null;
}
let j;
try {
j = JSON.parse(raw);
} catch {
return null;
}
const runId = extractRunId(journalPath) || j.runId || null;
if (!runId) return null;
const startedAt = toIso(j.startTime != null ? j.startTime : j.startedAt);
const durationMs = Number.isFinite(j.durationMs) ? j.durationMs : null;
let endedAt = toIso(j.endTime != null ? j.endTime : j.endedAt);
if (!endedAt && startedAt && durationMs != null) {
const t = Date.parse(startedAt);
if (!Number.isNaN(t)) endedAt = new Date(t + durationMs).toISOString();
}
const progress = Array.isArray(j.workflowProgress)
? j.workflowProgress
: Array.isArray(j.progress)
? j.progress
: [];
return {
runId,
taskId: j.taskId || null,
name: j.workflowName || j.name || nameFromScript(journalPath),
status: String(j.status || "completed"),
defaultModel: j.defaultModel || null,
startedAt,
endedAt,
durationMs,
agentCount: Number.isFinite(j.agentCount)
? j.agentCount
: progress.filter((e) => e && e.type === "workflow_agent").length,
totalTokens: Number.isFinite(j.totalTokens) ? j.totalTokens : 0,
totalToolCalls: Number.isFinite(j.totalToolCalls) ? j.totalToolCalls : 0,
phases: Array.isArray(j.phases) ? j.phases : [],
progress,
journalPath,
};
}
/**
* Ingest one parsed journal: upsert the workflow row, then link/create each
* inner agent. Returns the upserted workflow row, or null on failure.
*/
async function ingestWorkflowJournal(dbModule, sessionId, journal, opts = {}) {
const { stmts } = dbModule;
const mainAgentId = `${sessionId}-main`;
const ih = importHistory();
// Inner-agent transcripts live in a per-run nested dir, not the session's
// top-level subagents/. opts.sessionDir is the session transcript folder.
const agentDir = opts.sessionDir ? agentsDirForRun(opts.sessionDir, journal.runId) : null;
// Accumulate inner-agent token usage (real input/output/cache split from each
// transcript) so the run's spend can be folded into the session's cost.
const runTokens = {};
stmts.upsertWorkflow.run(
journal.runId,
sessionId,
journal.taskId,
journal.name,
journal.status,
journal.defaultModel,
journal.startedAt,
journal.endedAt,
journal.durationMs,
journal.agentCount,
journal.totalTokens,
journal.totalToolCalls,
JSON.stringify(journal.phases),
JSON.stringify(journal.progress),
opts.scriptPath || null,
journal.journalPath || null,
"journal"
);
// Only `workflow_agent` entries are real agents; `workflow_phase` entries are
// phase markers (kept in progress[] for the phase chips, skipped here).
const agentEntries = journal.progress.filter(
(e) => e && e.type === "workflow_agent" && e.agentId
);
for (const entry of agentEntries) {
const agentId = entry.agentId;
const jsonlId = `${sessionId}-jsonl-${agentId}`;
const status = mapState(entry.state);
const phase = entry.phaseTitle || null;
// subagent_type: prefer the label's prefix (e.g. "scout:starship" → "scout")
// for nicer grouping; otherwise the generic workflow-subagent type.
const subType =
(entry.label && entry.label.includes(":") ? entry.label.split(":")[0] : null) ||
entry.agentType ||
"workflow-subagent";
// Prefer parsing the real transcript so tool events + metadata land via the
// shared importer (idempotent, dedups by tool_use_id). Fall back to a
// minimal row built from the journal entry if the file is gone.
let parsed = null;
if (agentDir) {
const subPath = path.join(agentDir, `agent-${agentId}.jsonl`);
if (fs.existsSync(subPath)) {
try {
parsed = await ih.parseSubagentFile(subPath);
} catch {
parsed = null;
}
}
}
try {
if (parsed) {
ih.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, parsed);
mergeWorkflowTokens(runTokens, parsed.tokensByModel);
} else if (!stmts.getAgent.get(jsonlId)) {
stmts.insertAgent.run(
jsonlId,
sessionId,
entry.label || `Subagent ${String(agentId).slice(0, 8)}`,
"subagent",
subType,
status,
entry.label || entry.promptPreview || null,
mainAgentId,
JSON.stringify({
imported: true,
source: "workflow",
workflow_run_id: journal.runId,
model: entry.model || null,
tokens: entry.tokens || 0,
tool_calls: entry.toolCalls || 0,
})
);
}
// Stamp the workflow linkage + journal-authoritative status/phase.
stmts.setAgentWorkflow.run(journal.runId, phase, status, jsonlId);
} catch {
/* one bad agent must not abort the whole run ingest */
}
}
return { row: stmts.getWorkflow.get(journal.runId), tokens: runTokens };
}
function shortLabel(s) {
if (!s) return null;
const first = String(s).split("\n")[0].trim();
return first.length > 80 ? first.slice(0, 79) + "…" : first;
}
function safeStringify(v) {
if (v == null) return null;
if (typeof v === "string") return v;
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
function bucketTotal(tokensByModel) {
let n = 0;
for (const b of Object.values(tokensByModel || {})) {
n +=
(b.input || 0) +
(b.output || 0) +
(b.cacheRead || 0) +
(b.cacheWrite || 0) +
(b.cacheWrite1h || 0);
}
return n;
}
/**
* Live ingest for a RUNNING workflow — before its terminal wf_<runId>.json
* exists. Builds progress[] + aggregates in real time from the streaming
* `<runDir>/journal.jsonl` (started/result events per agent) plus the growing
* `<runDir>/agent-<id>.jsonl` transcripts (real token/tool/duration usage via
* parseSubagentFile). Phase/label aren't available live (those come from the
* terminal journal), so phaseTitle is null and label falls back to the agent's
* prompt. The fast poll re-runs this as the files grow, so tokens/tools/agents
* update live. Returns { row, tokens } or null.
*/
async function ingestLiveWorkflow(dbModule, sessionId, sessionDir, runId, scriptPath) {
const { stmts } = dbModule;
const mainAgentId = `${sessionId}-main`;
const ih = importHistory();
const dir = agentsDirForRun(sessionDir, runId);
if (!fs.existsSync(dir)) return null;
// Streaming journal: which agents started / finished (+ their result payload).
const started = new Set();
const doneResults = new Map();
try {
const jj = path.join(dir, "journal.jsonl");
if (fs.existsSync(jj)) {
for (const line of fs.readFileSync(jj, "utf8").split("\n")) {
if (!line.trim()) continue;
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
if (!o || !o.agentId) continue;
if (o.type === "started") started.add(o.agentId);
else if (o.type === "result") doneResults.set(o.agentId, o.result);
}
}
} catch {
/* ignore */
}
let agentFiles = [];
try {
agentFiles = fs.readdirSync(dir).filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl"));
} catch {
return null;
}
if (agentFiles.length === 0 && started.size === 0) return null;
const progress = [];
const runTokens = {};
let totalTokens = 0;
let totalToolCalls = 0;
let earliest = null;
let latest = null;
let model = null;
for (const f of agentFiles) {
const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/, "");
let parsed = null;
try {
parsed = await ih.parseSubagentFile(path.join(dir, f));
} catch {
parsed = null;
}
const done = doneResults.has(agentId);
const state = done ? "done" : "running";
const aTok = parsed ? bucketTotal(parsed.tokensByModel) : 0;
const tools = parsed && parsed.toolNames ? parsed.toolNames : [];
const startedAt = parsed && parsed.startedAt ? parsed.startedAt : null;
const endedAt = parsed && parsed.endedAt ? parsed.endedAt : null;
const durationMs = startedAt && endedAt ? Date.parse(endedAt) - Date.parse(startedAt) : null;
const label = parsed && parsed.task ? shortLabel(parsed.task) : null;
if (parsed && parsed.model && !model) model = parsed.model;
totalTokens += aTok;
totalToolCalls += tools.length;
if (startedAt) {
const ts = Date.parse(startedAt);
if (!earliest || ts < earliest) earliest = ts;
}
if (endedAt) {
const ts = Date.parse(endedAt);
if (!latest || ts > latest) latest = ts;
}
progress.push({
type: "workflow_agent",
agentId,
label,
phaseTitle: null,
model: parsed ? parsed.model : null,
state,
tokens: aTok,
toolCalls: tools.length,
durationMs,
lastToolName: tools.length ? tools[tools.length - 1] : null,
promptPreview: parsed ? parsed.task : null,
resultPreview: done ? safeStringify(doneResults.get(agentId)) : null,
});
try {
const jsonlId = `${sessionId}-jsonl-${agentId}`;
if (parsed) {
ih.importSubagentFromJsonl(dbModule, sessionId, mainAgentId, parsed);
mergeWorkflowTokens(runTokens, parsed.tokensByModel);
} else if (!stmts.getAgent.get(jsonlId)) {
stmts.insertAgent.run(
jsonlId,
sessionId,
label || `Subagent ${agentId.slice(0, 8)}`,
"subagent",
"workflow-subagent",
mapState(state),
label,
mainAgentId,
JSON.stringify({ imported: true, source: "workflow-live", workflow_run_id: runId })
);
}
stmts.setAgentWorkflow.run(runId, null, mapState(state), jsonlId);
} catch {
/* one bad agent must not abort the live ingest */
}
}
// Agents that have a `started` event but no transcript file yet (queued).
for (const agentId of started) {
if (agentFiles.includes(`agent-${agentId}.jsonl`)) continue;
progress.push({
type: "workflow_agent",
agentId,
label: null,
phaseTitle: null,
model: null,
state: doneResults.has(agentId) ? "done" : "running",
tokens: 0,
toolCalls: 0,
durationMs: null,
lastToolName: null,
});
}
let startedAtIso = earliest ? new Date(earliest).toISOString() : null;
if (!startedAtIso && scriptPath) {
try {
startedAtIso = new Date(fs.statSync(scriptPath).mtimeMs).toISOString();
} catch {
/* ignore */
}
}
const durationMs = earliest && latest ? latest - earliest : null;
stmts.upsertWorkflow.run(
runId,
sessionId,
null,
scriptPath ? nameFromScript(scriptPath) : runId,
"running",
model,
startedAtIso,
null,
durationMs,
progress.length,
totalTokens,
totalToolCalls,
null,
JSON.stringify(progress),
scriptPath || null,
null,
"live"
);
return { row: stmts.getWorkflow.get(runId), tokens: runTokens };
}
/**
* Detect running workflows: a launch script whose journal hasn't landed yet.
* Upsert a minimal `running` row so the UI shows it before completion. Skips
* runs that already have a completed/error row (the journal won.) Returns the
* upserted rows.
*/
function detectRunningWorkflows(dbModule, sessionId, paths, handledRunIds) {
const { stmts } = dbModule;
const changed = [];
for (const scriptPath of paths.scripts) {
const runId = extractRunId(scriptPath);
if (!runId || handledRunIds.has(runId)) continue;
const existing = stmts.getWorkflow.get(runId);
if (existing && existing.status !== "running") continue; // journal already won
let startedAt = null;
let agentCount = 0;
try {
const st = fs.statSync(scriptPath);
startedAt = new Date(st.mtimeMs).toISOString();
} catch {
/* ignore */
}
// Best-effort fleet size: inner-agent transcripts in this run's nested dir.
try {
const agentDir = paths.sessionDir ? agentsDirForRun(paths.sessionDir, runId) : null;
if (agentDir && fs.existsSync(agentDir)) {
agentCount = fs
.readdirSync(agentDir)
.filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl")).length;
}
} catch {
/* ignore */
}
stmts.upsertWorkflow.run(
runId,
sessionId,
null,
nameFromScript(scriptPath),
"running",
null,
startedAt,
null,
null,
agentCount,
0,
0,
null,
null,
scriptPath,
null,
"live"
);
changed.push(stmts.getWorkflow.get(runId));
}
return changed;
}
/**
* Ingest every workflow artifact for one session: completed journals first,
* then running detection for journal-less launch scripts.
*
* @param {object} dbModule - { db, stmts }
* @param {{id: string, transcript_path?: string, cwd?: string}} session
* @returns {Promise<object[]>} the workflow rows that were inserted/updated
*/
async function ingestWorkflowsForSession(dbModule, session) {
const sessionId = session && session.id;
if (!sessionId) return [];
const transcriptPath = resolveTranscriptPath(session);
if (!transcriptPath) return [];
const paths = findSessionWorkflows(transcriptPath);
if (paths.journals.length === 0 && paths.scripts.length === 0 && paths.liveRuns.length === 0) {
return [];
}
const changed = [];
const journalRunIds = new Set();
// Session-wide accumulator of inner-agent token usage across all runs, so the
// session's cost includes workflow spend. Recomputed in full each call (all
// journals are re-parsed) → writeSessionTokens replace semantics make it
// idempotent (no double-count across re-ingests).
const workflowTokens = {};
// Map runId → its launch script (so a journal row records script_path too).
const scriptByRun = new Map();
for (const s of paths.scripts) scriptByRun.set(extractRunId(s), s);
for (const journalPath of paths.journals) {
try {
const journal = parseWorkflowJournal(journalPath);
if (!journal) continue;
journalRunIds.add(journal.runId);
const res = await ingestWorkflowJournal(dbModule, sessionId, journal, {
sessionDir: paths.sessionDir,
scriptPath: scriptByRun.get(journal.runId) || null,
});
if (res && res.row) changed.push(res.row);
if (res && res.tokens) mergeWorkflowTokens(workflowTokens, res.tokens);
} catch {
/* skip malformed journal */
}
}
// Live runs (no terminal journal yet): build real-time progress + tokens from
// the streaming journal.jsonl + growing agent transcripts.
const liveHandled = new Set();
for (const lr of paths.liveRuns) {
if (journalRunIds.has(lr.runId)) continue; // terminal journal is authoritative
try {
const res = await ingestLiveWorkflow(
dbModule,
sessionId,
paths.sessionDir,
lr.runId,
scriptByRun.get(lr.runId) || null
);
if (res && res.row) {
changed.push(res.row);
liveHandled.add(lr.runId);
}
if (res && res.tokens) mergeWorkflowTokens(workflowTokens, res.tokens);
} catch {
/* non-fatal — partial live run */
}
}
try {
const handled = new Set([...journalRunIds, ...liveHandled]);
changed.push(...detectRunningWorkflows(dbModule, sessionId, paths, handled));
} catch {
/* non-fatal */
}
// Fold the workflow fleet's token usage into the session cost under a
// namespaced `workflow` service_tier (isolated from the main-transcript
// writer's buckets). getTokensBySession + calculateCost sum it per model.
try {
if (Object.keys(workflowTokens).length > 0) {
importHistory().writeSessionTokens(dbModule, sessionId, workflowTokens);
}
} catch {
/* non-fatal — cost folding must never break ingestion */
}
return changed;
}
/**
* One-time backfill: ingest workflow artifacts for every recorded session.
* Used by the legacy auto-import on first boot so historical completed
* workflows surface. Idempotent and fail-safe per session.
*
* @returns {Promise<{sessions: number, workflows: number}>}
*/
async function ingestAllWorkflows(dbModule) {
const { db } = dbModule;
let rows = [];
try {
rows = db.prepare("SELECT id, cwd, transcript_path FROM sessions").all();
} catch {
return { sessions: 0, workflows: 0 };
}
let sessions = 0;
let workflows = 0;
for (const row of rows) {
try {
const changed = await ingestWorkflowsForSession(dbModule, {
id: row.id,
cwd: row.cwd,
transcript_path: row.transcript_path,
});
if (changed.length > 0) {
sessions++;
workflows += changed.length;
}
} catch {
/* non-fatal — skip this session */
}
}
return { sessions, workflows };
}
/**
* Cheap change-fingerprint for a session's workflow artifacts: the newest mtime
* across its journals, launch scripts, and — crucially for real-time — the
* streaming files of any RUNNING run (journal.jsonl + agent-*.jsonl), so the
* poll re-ingests as a live workflow's tokens/agents grow. Per-file statting is
* bounded to runs without a terminal journal; completed runs contribute only
* their (stable) terminal-journal mtime. Returns 0 when nothing exists.
*/
function workflowsMaxMtime(transcriptPath) {
const { journals, scripts, liveRuns } = findSessionWorkflows(transcriptPath);
let max = 0;
const stat = (p) => {
try {
const m = fs.statSync(p).mtimeMs;
if (m > max) max = m;
} catch {
/* ignore */
}
};
for (const p of [...journals, ...scripts]) stat(p);
const completed = new Set(journals.map(extractRunId));
for (const lr of liveRuns) {
if (completed.has(lr.runId)) continue; // terminal journal mtime already counted
try {
for (const f of fs.readdirSync(lr.dir)) {
if (f.endsWith(".jsonl")) stat(path.join(lr.dir, f));
}
} catch {
/* ignore */
}
}
return max;
}
module.exports = {
ingestWorkflowsForSession,
ingestAllWorkflows,
ingestLiveWorkflow,
workflowsMaxMtime,
findSessionWorkflows,
parseWorkflowJournal,
ingestWorkflowJournal,
detectRunningWorkflows,
extractRunId,
nameFromScript,
mapState,
};
+661
View File
@@ -0,0 +1,661 @@
/**
* @file Git worktree management for lanes: creation, reset, removal, and the
* three-check destroy guard that stands between a mis-click and a user's real
* project directory. Every destructive function (resetWorktree, removeWorktree)
* verifies the lane against all three safety checks before touching git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "ccam-lanes");
const PROTECTED_BRANCHES = new Set(["main", "master"]);
/**
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
* Treats zero exit code as success even if stderr has hints.
*
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
* that leak from parent processes. Without this, git operations on a worktree (where
* .git is a file, not a directory) fail with ".git/index: index file open failed:
* Not a directory" when run from within a git hook or from a shell that inherited
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(cwd, args) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
// GIT_CONFIG_COUNT + GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n inject arbitrary git
// config into every invocation — including core.hooksPath, which would make an
// untrusted repo run our git commands' hooks. GIT_CONFIG_GLOBAL/SYSTEM do the
// same by redirecting which config files are read. All are scrubbed.
for (const name of Object.keys(env)) {
if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete env[name];
}
// Prevent credential prompts from hanging a background provisioning job
env.GIT_TERMINAL_PROMPT = "0";
try {
const result = await execFileAsync("git", args, {
cwd,
env,
maxBuffer: 8 * 1024 * 1024,
});
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
const error = new Error(`git ${args[0]} failed`);
error.code = err.code;
error.git = { args, code: err.code, stderr: err.stderr };
throw error;
}
}
/**
* Check if a directory is a git repository.
*/
async function isGitRepo(dir) {
try {
await git(dir, ["rev-parse", "--git-dir"]);
return true;
} catch {
return false;
}
}
/**
* List a repo's local branches plus its current HEAD branch, so a caller can
* offer a real picker instead of asking someone to remember a branch name.
* Local branches only (not `origin/*` refs) — those are what `addWorktree`
* can actually check a new worktree out onto without a fetch first.
*/
async function listBranches(sourceRepo) {
const result = await git(sourceRepo, [
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
]);
const branches = result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
let current = null;
try {
const head = await git(sourceRepo, ["symbolic-ref", "--short", "HEAD"]);
current = head.stdout.trim();
} catch {
// Detached HEAD: no current branch, and that's fine - the caller still
// gets the full branch list to choose a base from.
}
return { branches, current };
}
/**
* Resolve the base branch: try origin/<wanted>, then <wanted>, then HEAD.
*/
async function resolveBase(sourceRepo, wanted) {
// Try origin/<wanted>
try {
await git(sourceRepo, ["rev-parse", "--verify", "--quiet", `origin/${wanted}`]);
return wanted;
} catch {
// Fall through to next attempt
}
// Try <wanted>
try {
await git(sourceRepo, ["rev-parse", "--verify", "--quiet", wanted]);
return wanted;
} catch {
// Fall through to next attempt
}
// Fall back to current HEAD
const result = await git(sourceRepo, ["rev-parse", "--abbrev-ref", "HEAD"]);
return result.stdout.trim();
}
/**
* Slugify a title into a safe branch-name segment: lowercase, non-alphanumerics to `-`,
* collapsed, trimmed, max 40 chars. Throws EBADSLUG if result is empty.
*/
function slugify(text) {
const result = text
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
if (!result) {
const err = new Error("slug is empty");
err.code = "EBADSLUG";
throw err;
}
return result;
}
/**
* Parse `git worktree list --porcelain` output.
* Records are separated by blank lines, with keys like:
* - worktree <path>
* - branch refs/heads/<name>
* - locked (optional, bare line)
*/
async function listWorktrees(sourceRepo) {
const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]);
const lines = result.stdout.split("\n");
const worktrees = [];
let current = {};
for (const line of lines) {
if (!line.trim()) {
if (current.path) {
worktrees.push(current);
current = {};
}
continue;
}
if (line.startsWith("worktree ")) {
current.path = line.slice("worktree ".length);
} else if (line.startsWith("branch ")) {
const branchPath = line.slice("branch ".length);
// Strip refs/heads/ prefix
current.branch = branchPath.replace(/^refs\/heads\//, "");
} else if (line === "locked") {
current.locked = true;
}
}
if (current.path) {
worktrees.push(current);
}
return worktrees;
}
/**
* Find which worktree (if any) has a given branch checked out.
*/
async function branchCheckedOutAt(sourceRepo, branch) {
const worktrees = await listWorktrees(sourceRepo);
const found = worktrees.find((w) => w.branch === branch);
return found ? found.path : null;
}
/**
* Create a new worktree, or add an existing branch to a new worktree.
* - If branch is already checked out elsewhere, throw EBRANCHBUSY.
* - If branch doesn't exist, create it with -b from base.
* - If branch exists, add without -b (reuse existing).
*/
async function addWorktree({ sourceRepo, dir, branch, base }) {
// Check if branch is already checked out elsewhere
const checkedOutAt = await branchCheckedOutAt(sourceRepo, branch);
if (checkedOutAt) {
const err = new Error(`branch ${branch} already checked out at ${checkedOutAt}`);
err.code = "EBRANCHBUSY";
err.checkedOutAt = checkedOutAt;
throw err;
}
// Ensure parent directory exists
fs.mkdirSync(path.dirname(dir), { recursive: true });
// Check if branch already exists
let branchExists = false;
try {
await git(sourceRepo, ["rev-parse", "--verify", "--quiet", branch]);
branchExists = true;
} catch {
// Branch doesn't exist, we'll create it with -b
}
if (branchExists) {
// Branch exists, use it
await git(sourceRepo, ["worktree", "add", dir, branch]);
return { dir, branch, created: false };
} else {
// Branch doesn't exist, create it from base
await git(sourceRepo, ["worktree", "add", "-b", branch, dir, base]);
return { dir, branch, created: true };
}
}
/**
* Delete a branch safely: never delete protected branches or falsy branch.
*/
async function deleteBranchSafely(sourceRepo, branch, baseBranch) {
if (!branch) {
return; // Branch is falsy, don't delete
}
if (PROTECTED_BRANCHES.has(branch)) {
return; // Protected branch
}
if (baseBranch && branch === baseBranch) {
return; // Never delete the base branch
}
try {
await git(sourceRepo, ["branch", "-D", branch]);
} catch {
// Ignore deletion failures
}
}
/**
* Check 1 on its own: only a dashboard-provisioned worktree may ever be
* destroyed. Shared with removeWorktree's prune path, which cannot run checks 2
* and 3 as written (there is no directory left to resolve) but must still refuse
* an adopted lane outright.
*/
function assertManaged(lane) {
if (lane.kind !== "managed") {
const err = new Error(`lane kind is ${lane.kind}, not managed`);
err.code = "ENOTMANAGED";
throw err;
}
}
/**
* Check 2 for a path that may no longer exist: is this lane's RECORDED cwd
* inside LANES_ROOT on a path boundary? Purely lexical after resolving
* LANES_ROOT itself, because a hand-deleted worktree cannot be realpath'd.
* assertDestroyable still realpaths a live cwd, which additionally defeats
* symlinks; this weaker form only ever gates operations that touch git
* bookkeeping, never a directory.
*/
function isInsideLanesRoot(cwd) {
let resolvedRoot;
try {
resolvedRoot = fs.realpathSync(LANES_ROOT);
} catch {
return false;
}
const relativePath = path.relative(resolvedRoot, path.resolve(cwd));
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}
/**
* Three checks for whether a lane can be safely destroyed:
* 1. kind must be "managed" (not "adopted")
* 2. cwd must resolve to a path inside LANES_ROOT
* 3. The path must be listed in git worktree list for the source repo
*/
async function assertDestroyable(lane) {
// Check 1: kind must be "managed"
assertManaged(lane);
// Check 2: cwd must be inside LANES_ROOT on a path boundary
let resolvedCwd;
let resolvedRoot;
try {
resolvedCwd = fs.realpathSync(lane.cwd);
} catch {
// Path doesn't exist, which means it's not a live worktree
const err = new Error(`lane cwd does not exist: ${lane.cwd}`);
err.code = "EOUTSIDEROOT";
throw err;
}
try {
resolvedRoot = fs.realpathSync(LANES_ROOT);
} catch {
// LANES_ROOT doesn't exist, so cwd can't be inside it
const err = new Error(`LANES_ROOT does not exist: ${LANES_ROOT}`);
err.code = "EOUTSIDEROOT";
throw err;
}
// Check that cwd is inside LANES_ROOT on a path boundary
const relativePath = path.relative(resolvedRoot, resolvedCwd);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
const err = new Error(`lane cwd is outside LANES_ROOT: ${resolvedCwd} not in ${resolvedRoot}`);
err.code = "EOUTSIDEROOT";
throw err;
}
// Check 3: path must be in worktree list.
// git keeps listing a hand-deleted worktree (as `prunable`), so realpath must
// be tolerated per entry: an entry we cannot resolve is simply not this lane.
// Throwing here failed reset/remove for every OTHER lane in the same repo with
// an ENOENT naming an unrelated directory.
const worktrees = await listWorktrees(lane.source_repo);
const exists = worktrees.some((w) => {
try {
return fs.realpathSync(w.path) === resolvedCwd;
} catch {
return false;
}
});
if (!exists) {
const err = new Error(`lane is not listed as a worktree in ${lane.source_repo}`);
err.code = "ENOTWORKTREE";
throw err;
}
}
/**
* Reset a worktree to its base branch: checkout base, reset hard, clean files,
* then reset the feature branch to the base. This uses git branch -f from a
* separate working directory context to avoid worktree association restrictions.
*
* Verifies the base branch exists BEFORE any mutations, and verifies the final
* state ends on the feature branch (not left on base or detached).
*/
async function resetWorktree(lane) {
// Safety check
await assertDestroyable(lane);
const { cwd, branch, source_repo: sourceRepo, base_branch: baseBranch } = lane;
// CRITICAL: Verify base branch exists BEFORE any mutations.
// If base doesn't exist, we can't safely reset anything.
try {
await git(cwd, ["rev-parse", "--verify", baseBranch]);
} catch {
const err = new Error(`base branch does not exist: ${baseBranch}`);
err.code = "ENOBASE";
throw err;
}
// Fetch and prune (tolerate failure if no remote)
try {
await git(cwd, ["fetch", "origin", "--prune"]);
} catch {
// Ignore: may not have a remote
}
// Checkout base. Should succeed now that we've verified it exists.
try {
await git(cwd, ["checkout", baseBranch]);
} catch {
// Doesn't exist locally, try to create from remote (may fail if no remote)
try {
await git(cwd, ["checkout", "-b", baseBranch, `origin/${baseBranch}`]);
} catch {
// If both failed but rev-parse passed, the branch exists but we can't check it out
// Try the reset anyway - it might work even if checkout failed
}
}
// Reset hard to base
await git(cwd, ["reset", "--hard", baseBranch]);
// Clean untracked files (but NOT ignored files, so -x is omitted)
await git(cwd, ["clean", "-fd"]);
// Reset the feature branch. Git worktrees prevent deletion/force-update of
// "their" branch, so we reset it in-place instead: checkout → reset hard.
// ponytail: worktree association blocks deletion, reset-in-place instead
try {
// Try to checkout the feature branch
await git(cwd, ["checkout", branch]);
// Reset the current branch (feat/branch) to base
await git(cwd, ["reset", "--hard", baseBranch]);
} catch {
// If checkout fails, the branch might not exist. Create it.
try {
await git(cwd, ["checkout", "-b", branch, baseBranch]);
} catch (err) {
// If both checkout and create failed, we're in trouble
throw err;
}
}
// CRITICAL: Verify we actually ended on the feature branch.
// If this fails, the reset succeeded but left us on the wrong branch.
const currentBranch = (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
if (currentBranch !== branch) {
const err = new Error(`reset ended on wrong branch: expected ${branch}, got ${currentBranch}`);
err.code = "ERESETBRANCH";
throw err;
}
// Try to clean up by deleting the branch from source repo (may fail if still in use)
await deleteBranchSafely(sourceRepo, branch, baseBranch);
}
/**
* Locate a worktree's administrative directory under the source repo's common
* dir (`<common>/worktrees/<name>`) by matching the `gitdir` file each entry
* points at against the worktree's cwd. That file's content is the absolute
* path to the worktree's OWN `.git` file, so its dirname is the worktree path
* — this still works when that `.git` file is corrupt, since we only ever
* read it from the source repo's side. Returns null if no entry matches.
*/
async function findWorktreeAdminDir(sourceRepo, cwd) {
const common = (await git(sourceRepo, ["rev-parse", "--git-common-dir"])).stdout.trim();
const worktreesDir = path.join(path.resolve(sourceRepo, common), "worktrees");
let entries;
try {
entries = fs.readdirSync(worktreesDir);
} catch {
return null;
}
const resolvedCwd = path.resolve(cwd);
for (const name of entries) {
let pointer;
try {
pointer = fs.readFileSync(path.join(worktreesDir, name, "gitdir"), "utf8").trim();
} catch {
continue;
}
if (path.resolve(path.dirname(pointer)) === resolvedCwd) {
return path.join(worktreesDir, name);
}
}
return null;
}
/**
* Remove a worktree completely: unlock, remove, prune, delete branch.
*
* When the directory was deleted by hand there is nothing on disk to destroy,
* but git still registers the worktree and the branch — so that case takes the
* prune path instead of the full three checks, which cannot resolve a path that
* no longer exists. Checks 1 and 2 still hold there (an adopted lane is refused
* outright; the recorded cwd must still be inside LANES_ROOT), and the operation
* touches only git bookkeeping in the source repo. Check 3 is what the prune
* replaces: a worktree git no longer lists needs no removal at all.
*/
async function removeWorktree(lane) {
const { cwd, branch, source_repo: sourceRepo, base_branch: baseBranch } = lane;
if (!fs.existsSync(cwd)) {
assertManaged(lane);
if (!isInsideLanesRoot(cwd)) {
const err = new Error(`lane cwd is outside LANES_ROOT: ${cwd} not in ${LANES_ROOT}`);
err.code = "EOUTSIDEROOT";
throw err;
}
// Drops git's record of the vanished worktree. A no-op when git never knew
// it, which leaves only the branch to clean up.
await git(sourceRepo, ["worktree", "prune"]);
const stillListed = (await listWorktrees(sourceRepo)).some(
(w) => path.resolve(w.path) === path.resolve(cwd)
);
if (stillListed) {
await git(sourceRepo, ["worktree", "remove", "--force", cwd]);
}
await deleteBranchSafely(sourceRepo, branch, baseBranch);
return;
}
// Safety check
await assertDestroyable(lane);
// Unlock (ignore failure)
try {
await git(sourceRepo, ["worktree", "unlock", cwd]);
} catch {
// Ignore
}
// Remove the worktree (force). Git validates the worktree's OWN `.git`
// pointer before it will touch it, and refuses outright (even with a
// second --force) when that pointer is corrupt — the three checks above
// already proved this is a real, managed worktree of this repo, so fall
// back to deregistering it directly from the source repo's bookkeeping
// rather than leaving the lane permanently stuck. This never touches the
// worktree directory itself — only `<sourceRepo>/.git/worktrees/<name>`.
try {
await git(sourceRepo, ["worktree", "remove", "--force", cwd]);
} catch (removeErr) {
const adminDir = await findWorktreeAdminDir(sourceRepo, cwd);
if (!adminDir) throw removeErr;
fs.rmSync(adminDir, { recursive: true, force: true });
}
// Prune dead worktree entries
await git(sourceRepo, ["worktree", "prune"]);
// Delete the branch safely
await deleteBranchSafely(sourceRepo, branch, baseBranch);
}
/**
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
* Lines starting with ?? are untracked; others are dirty.
*/
async function statusCounts(dir) {
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
let dirty = 0;
let untracked = 0;
for (const line of result.stdout.split("\n")) {
if (!line.trim()) continue;
if (line.startsWith("??")) {
untracked++;
} else {
dirty++;
}
}
// Get short commit hash
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
const head = headResult.stdout.trim();
return { dirty, untracked, head };
}
/**
* What a lane's working copy looks like right now: which branch it is on, the
* short HEAD, that commit's subject, and how much is uncommitted.
*
* Read-only and cheap, but it is three subprocesses, which is why it lives
* behind its own endpoint rather than inside the polled `GET /api/lanes`
* payload. A detached HEAD reports the literal `HEAD` that git returns — the
* caller shows what git says rather than inventing a nicer word for it.
*/
async function gitFacts(dir) {
const { dirty, untracked, head } = await statusCounts(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
return { branch, head, subject, dirty, untracked };
}
/**
* Count the commits a destructive action would actually discard.
*
* With remotes configured: commits on no remote (`--not --remotes HEAD`).
*
* With NO remotes: the commits ahead of the lane's base branch
* (`<base>..HEAD`) — the work that belongs to this lane. Counting the whole
* history instead made a freshly provisioned worktree in a local-only repo
* report every commit in the repo as unpushed and demand Force to discard
* commits a `reset --hard <base>` would never touch. The `no-remote` warning is
* what tells the user nothing is backed up.
*
* Falls back to the total commit count only when there is no usable base to
* measure against (an adopted lane has no `base_branch` at all).
* Returns 0 if the repository is corrupt or unborn.
*
* @param {string} dir - Working directory to count in.
* @param {string|null} [baseBranch] - The lane's base branch, when it has one.
*/
async function unpushedCount(dir, baseBranch = null) {
try {
// First check if there are any remotes
const remotesResult = await git(dir, ["remote"]);
const hasRemotes = !!remotesResult.stdout.trim();
if (hasRemotes) {
// Has remotes: count commits not on any remote
const result = await git(dir, ["rev-list", "--count", "--not", "--remotes", "HEAD"]);
return parseInt(result.stdout.trim(), 10);
}
if (baseBranch) {
try {
const result = await git(dir, ["rev-list", "--count", `${baseBranch}..HEAD`]);
return parseInt(result.stdout.trim(), 10);
} catch {
// Base branch is gone or never existed — fall through to the total.
}
}
// No remote and no usable base: every commit is at risk, so count them all.
const result = await git(dir, ["rev-list", "--count", "HEAD"]);
return parseInt(result.stdout.trim(), 10);
} catch {
// Repository error (unborn HEAD, corrupt, etc.)
return 0;
}
}
/**
* Check if the repository has no remotes configured at all.
* Returns true if `git remote` output is empty, false otherwise.
*/
async function hasNoRemotes(dir) {
try {
const result = await git(dir, ["remote"]);
return !result.stdout.trim();
} catch {
// Assume remotes exist if we can't query
return false;
}
}
module.exports = {
LANES_ROOT,
git,
isGitRepo,
listBranches,
resolveBase,
slugify,
listWorktrees,
branchCheckedOutAt,
addWorktree,
assertManaged,
isInsideLanesRoot,
assertDestroyable,
resetWorktree,
removeWorktree,
statusCounts,
gitFacts,
unpushedCount,
hasNoRemotes,
};