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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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.
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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 18–22 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user