@hellcoder/companion 0.107.1-preview.20260708075901.9f18d7b → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentsPage-t4s7ZO2P.js → AgentsPage-DI5hdCnz.js} +5 -5
- package/dist/assets/{CronManager-C_0weC_z.js → CronManager-yX8i4C_S.js} +1 -1
- package/dist/assets/DashboardPage-D1nc0wHf.js +1 -0
- package/dist/assets/{IntegrationsPage-Gte53t7Z.js → IntegrationsPage-iSfvR9vX.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-DHJ73rFh.js → LinearOAuthSettingsPage-omExXsGc.js} +1 -1
- package/dist/assets/{LinearSettingsPage-D7ZHYkum.js → LinearSettingsPage-C4oBf1iO.js} +1 -1
- package/dist/assets/{Playground-DBWsv4R6.js → Playground-CjiZI9QY.js} +1 -1
- package/dist/assets/{PromptsPage-Cnjs3hD9.js → PromptsPage-CU6z49U6.js} +1 -1
- package/dist/assets/{RunsPage-B5PsNevy.js → RunsPage-mcU7OUoZ.js} +1 -1
- package/dist/assets/{SandboxManager-DpFA1it7.js → SandboxManager-BkYS-dK5.js} +1 -1
- package/dist/assets/SettingsPage-CyHMmTBB.js +1 -0
- package/dist/assets/{TailscalePage-CMG1g7e2.js → TailscalePage-CRyiyMcJ.js} +1 -1
- package/dist/assets/index-DMoY-FDD.css +1 -0
- package/dist/assets/index-DOle27vf.js +136 -0
- package/dist/assets/{sw-register-CS-6kwSW.js → sw-register-Dqdfho7x.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/auto-namer.test.ts +16 -0
- package/server/claude-cli-runner.ts +92 -0
- package/server/dashboard-scheduler.test.ts +73 -0
- package/server/dashboard-scheduler.ts +46 -0
- package/server/dashboard-store.test.ts +101 -0
- package/server/dashboard-store.ts +121 -0
- package/server/dashboard-summarizer.test.ts +216 -0
- package/server/dashboard-summarizer.ts +290 -0
- package/server/dashboard-types.ts +70 -0
- package/server/index.ts +4 -0
- package/server/linear-connections.test.ts +16 -0
- package/server/routes/dashboard-routes.test.ts +139 -0
- package/server/routes/dashboard-routes.ts +101 -0
- package/server/routes/settings-routes.ts +47 -1
- package/server/routes.test.ts +112 -0
- package/server/routes.ts +4 -0
- package/server/settings-manager.test.ts +12 -0
- package/server/settings-manager.ts +45 -1
- package/server/ws-bridge-codex.test.ts +24 -0
- package/dist/assets/SettingsPage-v8ndyXOz.js +0 -1
- package/dist/assets/index-Bp3e_166.js +0 -136
- package/dist/assets/index-D4AeOLki.css +0 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { DashboardStore } from "./dashboard-store.js";
|
|
6
|
+
import { _resetForTest } from "./settings-manager.js";
|
|
7
|
+
import { clearClaudeSessionHistoryCacheForTests } from "./claude-session-history.js";
|
|
8
|
+
|
|
9
|
+
// The summarizer runs prompts through the headless Claude CLI (same login as
|
|
10
|
+
// normal sessions). Tests either inject a fake runner or toggle the mocked CLI
|
|
11
|
+
// availability below — no network and no real CLI involved.
|
|
12
|
+
const cliMock = { available: true };
|
|
13
|
+
vi.mock("./claude-cli-runner.js", () => ({
|
|
14
|
+
isClaudeCliAvailable: () => cliMock.available,
|
|
15
|
+
runClaudePrompt: vi.fn().mockResolvedValue(null),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
_resetDashboardRunStateForTest,
|
|
20
|
+
buildTranscriptExcerpt,
|
|
21
|
+
DashboardCliUnavailableError,
|
|
22
|
+
parseSummaryResponse,
|
|
23
|
+
runDashboardUpdate,
|
|
24
|
+
} from "./dashboard-summarizer.js";
|
|
25
|
+
|
|
26
|
+
// Covers the three layers of the summarizer:
|
|
27
|
+
// 1. buildTranscriptExcerpt — bounded, tail-biased excerpt building
|
|
28
|
+
// 2. parseSummaryResponse — tolerant parsing of the model's JSON answer
|
|
29
|
+
// 3. runDashboardUpdate — end-to-end over fake transcripts with an injected
|
|
30
|
+
// prompt runner, including the incremental "only changed sessions" skip.
|
|
31
|
+
|
|
32
|
+
const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
33
|
+
|
|
34
|
+
/** Writes a minimal Claude Code transcript that discovery + history parsing accept. */
|
|
35
|
+
function writeTranscript(projectsRoot: string, sessionId: string, cwd: string): string {
|
|
36
|
+
const projectDir = join(projectsRoot, "-root-projects-demo");
|
|
37
|
+
mkdirSync(projectDir, { recursive: true });
|
|
38
|
+
const file = join(projectDir, `${sessionId}.jsonl`);
|
|
39
|
+
const lines = [
|
|
40
|
+
{ sessionId, cwd, gitBranch: "main", slug: "demo-task", type: "summary" },
|
|
41
|
+
{
|
|
42
|
+
sessionId,
|
|
43
|
+
type: "user",
|
|
44
|
+
timestamp: "2026-07-18T10:00:00Z",
|
|
45
|
+
message: { role: "user", content: "Please fix the login bug" },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
sessionId,
|
|
49
|
+
type: "assistant",
|
|
50
|
+
timestamp: "2026-07-18T10:01:00Z",
|
|
51
|
+
message: {
|
|
52
|
+
role: "assistant",
|
|
53
|
+
id: "msg_1",
|
|
54
|
+
content: [{ type: "text", text: "Fixed the redirect loop and added a test." }],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n"), "utf-8");
|
|
59
|
+
return file;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("buildTranscriptExcerpt", () => {
|
|
63
|
+
it("formats roles and keeps message order", () => {
|
|
64
|
+
const excerpt = buildTranscriptExcerpt([
|
|
65
|
+
{ role: "user", content: "do X" },
|
|
66
|
+
{ role: "assistant", content: "did X" },
|
|
67
|
+
]);
|
|
68
|
+
expect(excerpt).toBe("USER: do X\n\nASSISTANT: did X");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps the tail when the total budget is exceeded", () => {
|
|
72
|
+
const big = "x".repeat(1_400);
|
|
73
|
+
const messages = Array.from({ length: 40 }, (_, i) => ({
|
|
74
|
+
role: "user" as const,
|
|
75
|
+
content: `${i}:${big}`,
|
|
76
|
+
}));
|
|
77
|
+
const excerpt = buildTranscriptExcerpt(messages);
|
|
78
|
+
// The last message must survive; the first must be dropped.
|
|
79
|
+
expect(excerpt).toContain("39:");
|
|
80
|
+
expect(excerpt).not.toContain("USER: 0:");
|
|
81
|
+
expect(excerpt.length).toBeLessThanOrEqual(25_000);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("truncates oversized individual messages", () => {
|
|
85
|
+
const excerpt = buildTranscriptExcerpt([{ role: "user", content: "y".repeat(5_000) }]);
|
|
86
|
+
expect(excerpt).toContain("[... truncated]");
|
|
87
|
+
expect(excerpt.length).toBeLessThan(2_000);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("parseSummaryResponse", () => {
|
|
92
|
+
it("parses a clean JSON object", () => {
|
|
93
|
+
const parsed = parseSummaryResponse(
|
|
94
|
+
'{"summary":"Done.","status":"completed","openItems":[],"archivable":true}',
|
|
95
|
+
);
|
|
96
|
+
expect(parsed).toEqual({ summary: "Done.", status: "completed", openItems: [], archivable: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("extracts JSON wrapped in prose or markdown fences", () => {
|
|
100
|
+
const parsed = parseSummaryResponse(
|
|
101
|
+
'Here you go:\n```json\n{"summary":"WIP","status":"in_progress","openItems":["write tests"],"archivable":false}\n```',
|
|
102
|
+
);
|
|
103
|
+
expect(parsed?.status).toBe("in_progress");
|
|
104
|
+
expect(parsed?.openItems).toEqual(["write tests"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("falls back to in_progress for unknown status values and caps openItems", () => {
|
|
108
|
+
const parsed = parseSummaryResponse(
|
|
109
|
+
`{"summary":"s","status":"weird","openItems":["1","2","3","4","5","6","7"],"archivable":"yes"}`,
|
|
110
|
+
);
|
|
111
|
+
expect(parsed?.status).toBe("in_progress");
|
|
112
|
+
expect(parsed?.openItems).toHaveLength(5);
|
|
113
|
+
// Non-boolean archivable must not be treated as true.
|
|
114
|
+
expect(parsed?.archivable).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns null for garbage or missing summary", () => {
|
|
118
|
+
expect(parseSummaryResponse("no json here")).toBeNull();
|
|
119
|
+
expect(parseSummaryResponse('{"status":"completed"}')).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("runDashboardUpdate", () => {
|
|
124
|
+
let projectsRoot: string;
|
|
125
|
+
let storeDir: string;
|
|
126
|
+
let store: DashboardStore;
|
|
127
|
+
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
projectsRoot = mkdtempSync(join(tmpdir(), "dashboard-projects-"));
|
|
130
|
+
storeDir = mkdtempSync(join(tmpdir(), "dashboard-store-"));
|
|
131
|
+
store = new DashboardStore(storeDir);
|
|
132
|
+
cliMock.available = true;
|
|
133
|
+
_resetForTest(join(mkdtempSync(join(tmpdir(), "dashboard-settings-")), "settings.json"));
|
|
134
|
+
_resetDashboardRunStateForTest();
|
|
135
|
+
clearClaudeSessionHistoryCacheForTests();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
_resetForTest();
|
|
140
|
+
rmSync(projectsRoot, { recursive: true, force: true });
|
|
141
|
+
rmSync(storeDir, { recursive: true, force: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("throws when sessions need summarizing but the Claude CLI is missing", async () => {
|
|
145
|
+
writeTranscript(projectsRoot, SESSION_ID, "/root/projects/demo");
|
|
146
|
+
cliMock.available = false;
|
|
147
|
+
await expect(
|
|
148
|
+
runDashboardUpdate({ trigger: "manual", store, projectsRoot }),
|
|
149
|
+
).rejects.toBeInstanceOf(DashboardCliUnavailableError);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("succeeds without the CLI when there is nothing to summarize", async () => {
|
|
153
|
+
cliMock.available = false;
|
|
154
|
+
const meta = await runDashboardUpdate({ trigger: "manual", store, projectsRoot });
|
|
155
|
+
expect(meta.lastRunStatus).toBe("success");
|
|
156
|
+
expect(meta.sessionsProcessed).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("summarizes changed sessions via the runner and persists summary + run meta", async () => {
|
|
160
|
+
writeTranscript(projectsRoot, SESSION_ID, "/root/projects/demo");
|
|
161
|
+
|
|
162
|
+
const runner = vi.fn().mockResolvedValue(JSON.stringify({
|
|
163
|
+
summary: "Login bug fixed; regression test added.",
|
|
164
|
+
status: "completed",
|
|
165
|
+
openItems: [],
|
|
166
|
+
archivable: true,
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
const meta = await runDashboardUpdate({ trigger: "manual", store, projectsRoot, runner });
|
|
170
|
+
|
|
171
|
+
expect(meta.lastRunStatus).toBe("success");
|
|
172
|
+
expect(meta.sessionsProcessed).toBe(1);
|
|
173
|
+
expect(meta.sessionsFailed).toBe(0);
|
|
174
|
+
expect(store.loadRunMeta()).toEqual(meta);
|
|
175
|
+
|
|
176
|
+
const summary = store.loadSummary(SESSION_ID);
|
|
177
|
+
expect(summary?.status).toBe("completed");
|
|
178
|
+
expect(summary?.archivable).toBe(true);
|
|
179
|
+
expect(summary?.cwd).toBe("/root/projects/demo");
|
|
180
|
+
expect(summary?.model).toBe("claude-haiku-4-5");
|
|
181
|
+
|
|
182
|
+
// The runner receives the transcript excerpt and the configured dashboard model.
|
|
183
|
+
const [prompt, model] = runner.mock.calls[0];
|
|
184
|
+
expect(prompt).toContain("USER: Please fix the login bug");
|
|
185
|
+
expect(model).toBe("claude-haiku-4-5");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("skips sessions whose transcript is unchanged since the last run", async () => {
|
|
189
|
+
const file = writeTranscript(projectsRoot, SESSION_ID, "/root/projects/demo");
|
|
190
|
+
// Pin the mtime so both runs observe the same lastActivityAt.
|
|
191
|
+
utimesSync(file, new Date(1_700_000_000_000), new Date(1_700_000_000_000));
|
|
192
|
+
|
|
193
|
+
const runner = vi.fn().mockResolvedValue(JSON.stringify({
|
|
194
|
+
summary: "s", status: "in_progress", openItems: [], archivable: false,
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
await runDashboardUpdate({ trigger: "manual", store, projectsRoot, runner });
|
|
198
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
199
|
+
|
|
200
|
+
const second = await runDashboardUpdate({ trigger: "manual", store, projectsRoot, runner });
|
|
201
|
+
// No new activity → no additional runner call, session counted as skipped.
|
|
202
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
203
|
+
expect(second.sessionsProcessed).toBe(0);
|
|
204
|
+
expect(second.sessionsSkipped).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("records a failed run when the runner yields nothing", async () => {
|
|
208
|
+
writeTranscript(projectsRoot, SESSION_ID, "/root/projects/demo");
|
|
209
|
+
const runner = vi.fn().mockResolvedValue(null);
|
|
210
|
+
|
|
211
|
+
const meta = await runDashboardUpdate({ trigger: "scheduled", store, projectsRoot, runner });
|
|
212
|
+
expect(meta.lastRunStatus).toBe("error");
|
|
213
|
+
expect(meta.sessionsFailed).toBe(1);
|
|
214
|
+
expect(store.loadSummary(SESSION_ID)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { DEFAULT_DASHBOARD_MODEL, getSettings } from "./settings-manager.js";
|
|
2
|
+
import { isClaudeCliAvailable, runClaudePrompt } from "./claude-cli-runner.js";
|
|
3
|
+
import { discoverClaudeSessions, type DiscoveredClaudeSession } from "./claude-session-discovery.js";
|
|
4
|
+
import { getClaudeSessionHistoryPage } from "./claude-session-history.js";
|
|
5
|
+
import { DashboardStore, getDashboardStore } from "./dashboard-store.js";
|
|
6
|
+
import {
|
|
7
|
+
DASHBOARD_SESSION_STATUSES,
|
|
8
|
+
type DashboardRunMeta,
|
|
9
|
+
type DashboardRunProgress,
|
|
10
|
+
type DashboardRunTrigger,
|
|
11
|
+
type DashboardSessionStatus,
|
|
12
|
+
type DashboardSessionSummary,
|
|
13
|
+
} from "./dashboard-types.js";
|
|
14
|
+
|
|
15
|
+
// Bounded transcript excerpt so a huge session can't blow the token budget:
|
|
16
|
+
// keep only the tail of the conversation, and truncate individual messages.
|
|
17
|
+
const EXCERPT_MESSAGE_LIMIT = 40;
|
|
18
|
+
const EXCERPT_MAX_MESSAGE_CHARS = 1_500;
|
|
19
|
+
const EXCERPT_MAX_TOTAL_CHARS = 24_000;
|
|
20
|
+
const DISCOVERY_LIMIT = 500;
|
|
21
|
+
// Generous: each summarization pays Claude CLI startup cost on top of the model call.
|
|
22
|
+
const PER_SESSION_TIMEOUT_MS = 120_000;
|
|
23
|
+
|
|
24
|
+
interface ExcerptMessage {
|
|
25
|
+
role: "user" | "assistant";
|
|
26
|
+
content: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Builds a compact, tail-biased plain-text excerpt of a transcript.
|
|
31
|
+
* Exported for tests.
|
|
32
|
+
*/
|
|
33
|
+
export function buildTranscriptExcerpt(messages: ExcerptMessage[]): string {
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
let total = 0;
|
|
36
|
+
// Walk backwards from the end so the most recent context always survives.
|
|
37
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
38
|
+
const msg = messages[i];
|
|
39
|
+
const text = msg.content.trim();
|
|
40
|
+
if (!text) continue;
|
|
41
|
+
const clipped = text.length > EXCERPT_MAX_MESSAGE_CHARS
|
|
42
|
+
? `${text.slice(0, EXCERPT_MAX_MESSAGE_CHARS)}\n[... truncated]`
|
|
43
|
+
: text;
|
|
44
|
+
const line = `${msg.role === "user" ? "USER" : "ASSISTANT"}: ${clipped}`;
|
|
45
|
+
if (total + line.length > EXCERPT_MAX_TOTAL_CHARS) break;
|
|
46
|
+
total += line.length;
|
|
47
|
+
lines.push(line);
|
|
48
|
+
}
|
|
49
|
+
return lines.reverse().join("\n\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ParsedSessionSummary {
|
|
53
|
+
summary: string;
|
|
54
|
+
status: DashboardSessionStatus;
|
|
55
|
+
openItems: string[];
|
|
56
|
+
archivable: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tolerant parser for the model's JSON answer. Model-agnostic on purpose —
|
|
61
|
+
* works whether or not the configured model supports structured outputs.
|
|
62
|
+
* Exported for tests.
|
|
63
|
+
*/
|
|
64
|
+
export function parseSummaryResponse(raw: string): ParsedSessionSummary | null {
|
|
65
|
+
const start = raw.indexOf("{");
|
|
66
|
+
const end = raw.lastIndexOf("}");
|
|
67
|
+
if (start === -1 || end <= start) return null;
|
|
68
|
+
|
|
69
|
+
let parsed: Record<string, unknown>;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(raw.slice(start, end + 1)) as Record<string, unknown>;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const summary = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
|
|
77
|
+
if (!summary) return null;
|
|
78
|
+
|
|
79
|
+
const status = DASHBOARD_SESSION_STATUSES.includes(parsed.status as DashboardSessionStatus)
|
|
80
|
+
? (parsed.status as DashboardSessionStatus)
|
|
81
|
+
: "in_progress";
|
|
82
|
+
|
|
83
|
+
const openItems = Array.isArray(parsed.openItems)
|
|
84
|
+
? parsed.openItems.filter((item): item is string => typeof item === "string" && !!item.trim()).slice(0, 5)
|
|
85
|
+
: [];
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
summary: summary.slice(0, 1_000),
|
|
89
|
+
status,
|
|
90
|
+
openItems,
|
|
91
|
+
archivable: parsed.archivable === true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildPrompt(session: DiscoveredClaudeSession, excerpt: string): string {
|
|
96
|
+
return [
|
|
97
|
+
"You are generating a project-dashboard entry for a Claude Code (coding agent) session transcript.",
|
|
98
|
+
"Do not use any tools — analyze only the excerpt below and answer directly.",
|
|
99
|
+
"Answer with ONLY a JSON object — no prose, no markdown fences — with exactly these fields:",
|
|
100
|
+
`{"summary": "...", "status": "...", "openItems": ["..."], "archivable": true|false}`,
|
|
101
|
+
"",
|
|
102
|
+
'- "summary": 2-3 sentences on what the session is about and where it stands right now.',
|
|
103
|
+
'- "status": one of "completed" (task finished and verified/accepted), "in_progress" (work underway or clearly more to do), "awaiting_user" (agent asked a question or needs a decision/approval), "stalled" (errors, dead ends, or abandoned mid-task).',
|
|
104
|
+
'- "openItems": up to 5 short outstanding work items; empty array if nothing is left.',
|
|
105
|
+
'- "archivable": true only when the session is finished or clearly abandoned and keeping it active adds nothing.',
|
|
106
|
+
"",
|
|
107
|
+
`Project directory: ${session.cwd}`,
|
|
108
|
+
session.gitBranch ? `Git branch: ${session.gitBranch}` : "",
|
|
109
|
+
"",
|
|
110
|
+
"Transcript excerpt (tail of the session):",
|
|
111
|
+
excerpt,
|
|
112
|
+
].filter(Boolean).join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One prompt → one plain-text answer. The default runner goes through the
|
|
117
|
+
* headless Claude CLI (`claude --print`), so summarization authenticates with
|
|
118
|
+
* the same Claude Code login that normal sessions use — no API key required.
|
|
119
|
+
*/
|
|
120
|
+
export type SummarizerRunner = (prompt: string, model: string) => Promise<string | null>;
|
|
121
|
+
|
|
122
|
+
const defaultRunner: SummarizerRunner = (prompt, model) =>
|
|
123
|
+
runClaudePrompt({ prompt, model, timeoutMs: PER_SESSION_TIMEOUT_MS });
|
|
124
|
+
|
|
125
|
+
// ─── Run state (module singleton) ───────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
let progress: DashboardRunProgress = { state: "idle", total: 0, processed: 0, failed: 0 };
|
|
128
|
+
|
|
129
|
+
export function getDashboardRunProgress(): DashboardRunProgress {
|
|
130
|
+
return { ...progress };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isDashboardRunActive(): boolean {
|
|
134
|
+
return progress.state === "running";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sessionLabel(session: DiscoveredClaudeSession): string {
|
|
138
|
+
if (session.slug) return session.slug;
|
|
139
|
+
const parts = session.cwd.split("/").filter(Boolean);
|
|
140
|
+
return parts[parts.length - 1] || session.cwd;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface RunDashboardUpdateOptions {
|
|
144
|
+
trigger: DashboardRunTrigger;
|
|
145
|
+
store?: DashboardStore;
|
|
146
|
+
projectsRoot?: string;
|
|
147
|
+
/** Override the prompt runner (tests). Defaults to the headless Claude CLI. */
|
|
148
|
+
runner?: SummarizerRunner;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class DashboardRunActiveError extends Error {
|
|
152
|
+
constructor() {
|
|
153
|
+
super("A dashboard update is already running");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class DashboardCliUnavailableError extends Error {
|
|
158
|
+
constructor() {
|
|
159
|
+
super("Claude Code CLI not found — the dashboard summarizer uses your Claude Code login");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Runs one summarizer pass: summarize every discovered Claude session whose
|
|
165
|
+
* transcript changed since its stored summary. Sequential on purpose — this
|
|
166
|
+
* is a background job and we'd rather be slow than hit rate limits.
|
|
167
|
+
*/
|
|
168
|
+
export async function runDashboardUpdate(
|
|
169
|
+
options: RunDashboardUpdateOptions,
|
|
170
|
+
): Promise<DashboardRunMeta> {
|
|
171
|
+
if (isDashboardRunActive()) throw new DashboardRunActiveError();
|
|
172
|
+
|
|
173
|
+
const settings = getSettings();
|
|
174
|
+
const model = settings.dashboardModel?.trim() || DEFAULT_DASHBOARD_MODEL;
|
|
175
|
+
const maxSessions = settings.dashboardMaxSessionsPerRun;
|
|
176
|
+
const store = options.store || getDashboardStore();
|
|
177
|
+
const startedAt = Date.now();
|
|
178
|
+
|
|
179
|
+
const discovered = discoverClaudeSessions({
|
|
180
|
+
limit: DISCOVERY_LIMIT,
|
|
181
|
+
projectsRoot: options.projectsRoot,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Incremental: only sessions with activity since their last stored summary.
|
|
185
|
+
const stale = discovered.filter((session) => {
|
|
186
|
+
const existing = store.loadSummary(session.sessionId);
|
|
187
|
+
return !existing || session.lastActivityAt > existing.transcriptMtimeMs;
|
|
188
|
+
});
|
|
189
|
+
const queue = stale.slice(0, maxSessions);
|
|
190
|
+
const skipped = discovered.length - queue.length;
|
|
191
|
+
|
|
192
|
+
// Only the default (CLI-backed) runner needs the binary; an injected runner
|
|
193
|
+
// (tests) doesn't, and an empty queue never invokes the runner at all.
|
|
194
|
+
if (queue.length > 0 && !options.runner && !isClaudeCliAvailable()) {
|
|
195
|
+
throw new DashboardCliUnavailableError();
|
|
196
|
+
}
|
|
197
|
+
const runner = options.runner || defaultRunner;
|
|
198
|
+
|
|
199
|
+
progress = {
|
|
200
|
+
state: "running",
|
|
201
|
+
trigger: options.trigger,
|
|
202
|
+
total: queue.length,
|
|
203
|
+
processed: 0,
|
|
204
|
+
failed: 0,
|
|
205
|
+
startedAt,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
let failed = 0;
|
|
209
|
+
try {
|
|
210
|
+
for (const session of queue) {
|
|
211
|
+
progress = { ...progress, currentSession: sessionLabel(session) };
|
|
212
|
+
const ok = await summarizeOne(session, store, runner, model, options.projectsRoot);
|
|
213
|
+
if (!ok) failed++;
|
|
214
|
+
progress = {
|
|
215
|
+
...progress,
|
|
216
|
+
processed: progress.processed + 1,
|
|
217
|
+
failed,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const meta: DashboardRunMeta = {
|
|
222
|
+
lastRunAt: startedAt,
|
|
223
|
+
lastRunCompletedAt: Date.now(),
|
|
224
|
+
lastRunStatus: failed === 0 ? "success" : failed === queue.length && queue.length > 0 ? "error" : "partial",
|
|
225
|
+
lastRunError: failed > 0 ? `${failed} of ${queue.length} sessions failed to summarize` : undefined,
|
|
226
|
+
trigger: options.trigger,
|
|
227
|
+
model,
|
|
228
|
+
sessionsProcessed: queue.length - failed,
|
|
229
|
+
sessionsSkipped: skipped,
|
|
230
|
+
sessionsFailed: failed,
|
|
231
|
+
};
|
|
232
|
+
// Only a fully failed run keeps the previous meta's "last successful" story;
|
|
233
|
+
// we still persist it so the UI can surface the failure.
|
|
234
|
+
store.saveRunMeta(meta);
|
|
235
|
+
return meta;
|
|
236
|
+
} finally {
|
|
237
|
+
progress = { state: "idle", total: 0, processed: 0, failed: 0 };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function summarizeOne(
|
|
242
|
+
session: DiscoveredClaudeSession,
|
|
243
|
+
store: DashboardStore,
|
|
244
|
+
runner: SummarizerRunner,
|
|
245
|
+
model: string,
|
|
246
|
+
projectsRoot?: string,
|
|
247
|
+
): Promise<boolean> {
|
|
248
|
+
const history = getClaudeSessionHistoryPage({
|
|
249
|
+
sessionId: session.sessionId,
|
|
250
|
+
limit: EXCERPT_MESSAGE_LIMIT,
|
|
251
|
+
projectsRoot,
|
|
252
|
+
});
|
|
253
|
+
if (!history || history.messages.length === 0) return false;
|
|
254
|
+
|
|
255
|
+
const excerpt = buildTranscriptExcerpt(
|
|
256
|
+
history.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
257
|
+
);
|
|
258
|
+
if (!excerpt) return false;
|
|
259
|
+
|
|
260
|
+
const raw = await runner(buildPrompt(session, excerpt), model);
|
|
261
|
+
if (!raw) return false;
|
|
262
|
+
|
|
263
|
+
const parsed = parseSummaryResponse(raw);
|
|
264
|
+
if (!parsed) {
|
|
265
|
+
console.warn(`[dashboard] Unparseable summarizer response for session ${session.sessionId}`);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const summary: DashboardSessionSummary = {
|
|
270
|
+
sessionId: session.sessionId,
|
|
271
|
+
cwd: session.cwd,
|
|
272
|
+
gitBranch: session.gitBranch,
|
|
273
|
+
slug: session.slug,
|
|
274
|
+
summary: parsed.summary,
|
|
275
|
+
status: parsed.status,
|
|
276
|
+
openItems: parsed.openItems,
|
|
277
|
+
archivable: parsed.archivable,
|
|
278
|
+
transcriptMtimeMs: session.lastActivityAt,
|
|
279
|
+
lastActivityAt: session.lastActivityAt,
|
|
280
|
+
summarizedAt: Date.now(),
|
|
281
|
+
model,
|
|
282
|
+
};
|
|
283
|
+
store.saveSummary(summary);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Test hook — reset the module-level run state. */
|
|
288
|
+
export function _resetDashboardRunStateForTest(): void {
|
|
289
|
+
progress = { state: "idle", total: 0, processed: 0, failed: 0 };
|
|
290
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ─── Project Dashboard types ────────────────────────────────────────────────
|
|
2
|
+
// The dashboard never reads live session data. A nightly (or manually
|
|
3
|
+
// triggered) summarizer job writes per-session summaries + a run-meta record
|
|
4
|
+
// to ~/.companion/dashboard/, and the dashboard UI reads only that store.
|
|
5
|
+
|
|
6
|
+
/** Where a session stands, as classified by the summarizer LLM. */
|
|
7
|
+
export type DashboardSessionStatus =
|
|
8
|
+
| "completed"
|
|
9
|
+
| "in_progress"
|
|
10
|
+
| "awaiting_user"
|
|
11
|
+
| "stalled";
|
|
12
|
+
|
|
13
|
+
export const DASHBOARD_SESSION_STATUSES: DashboardSessionStatus[] = [
|
|
14
|
+
"completed",
|
|
15
|
+
"in_progress",
|
|
16
|
+
"awaiting_user",
|
|
17
|
+
"stalled",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export interface DashboardSessionSummary {
|
|
21
|
+
/** Claude CLI session id — the transcript's .jsonl basename. */
|
|
22
|
+
sessionId: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
gitBranch?: string;
|
|
25
|
+
/** Session slug from the transcript metadata, if present. */
|
|
26
|
+
slug?: string;
|
|
27
|
+
/** 2-3 sentence "where it stands" summary. */
|
|
28
|
+
summary: string;
|
|
29
|
+
status: DashboardSessionStatus;
|
|
30
|
+
/** Outstanding work items, empty when the task is done. */
|
|
31
|
+
openItems: string[];
|
|
32
|
+
/** Summarizer's hint that this session is finished/abandoned and safe to archive. */
|
|
33
|
+
archivable: boolean;
|
|
34
|
+
/** Transcript mtime observed when this summary was generated (incremental-skip key). */
|
|
35
|
+
transcriptMtimeMs: number;
|
|
36
|
+
/** Last activity timestamp of the session (= transcript mtime). */
|
|
37
|
+
lastActivityAt: number;
|
|
38
|
+
summarizedAt: number;
|
|
39
|
+
/** Model that produced this summary. */
|
|
40
|
+
model: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type DashboardRunTrigger = "manual" | "scheduled";
|
|
44
|
+
|
|
45
|
+
export type DashboardRunStatus = "success" | "partial" | "error";
|
|
46
|
+
|
|
47
|
+
export interface DashboardRunMeta {
|
|
48
|
+
/** Start of the last completed run (successful or not). */
|
|
49
|
+
lastRunAt: number;
|
|
50
|
+
lastRunCompletedAt: number;
|
|
51
|
+
lastRunStatus: DashboardRunStatus;
|
|
52
|
+
lastRunError?: string;
|
|
53
|
+
trigger: DashboardRunTrigger;
|
|
54
|
+
model: string;
|
|
55
|
+
sessionsProcessed: number;
|
|
56
|
+
sessionsSkipped: number;
|
|
57
|
+
sessionsFailed: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Live progress of an in-flight summarizer run (not persisted). */
|
|
61
|
+
export interface DashboardRunProgress {
|
|
62
|
+
state: "idle" | "running";
|
|
63
|
+
trigger?: DashboardRunTrigger;
|
|
64
|
+
total: number;
|
|
65
|
+
processed: number;
|
|
66
|
+
failed: number;
|
|
67
|
+
/** Short label (project dir name or slug) of the session being summarized. */
|
|
68
|
+
currentSession?: string;
|
|
69
|
+
startedAt?: number;
|
|
70
|
+
}
|
package/server/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { LinearAgentBridge } from "./linear-agent-bridge.js";
|
|
|
34
34
|
import { NoVncProxy } from "./novnc-proxy.js";
|
|
35
35
|
|
|
36
36
|
import { startPeriodicCheck, setServiceMode } from "./update-checker.js";
|
|
37
|
+
import { startDashboardScheduler } from "./dashboard-scheduler.js";
|
|
37
38
|
import {
|
|
38
39
|
startPeriodicCheck as startClaudeCompatPeriodicCheck,
|
|
39
40
|
} from "./claude-compat-checker.js";
|
|
@@ -369,6 +370,9 @@ restoreTailscaleFunnel(port).catch((err) => {
|
|
|
369
370
|
// ── Update checker ──────────────────────────────────────────────────────────
|
|
370
371
|
startPeriodicCheck();
|
|
371
372
|
|
|
373
|
+
// ── Nightly dashboard summarizer (opt-in via settings) ──────────────────────
|
|
374
|
+
startDashboardScheduler();
|
|
375
|
+
|
|
372
376
|
// ── Claude CLI compatibility checker — surfaces banner when CLI is 2.1.121+ ─
|
|
373
377
|
startClaudeCompatPeriodicCheck();
|
|
374
378
|
|
|
@@ -60,6 +60,10 @@ beforeEach(() => {
|
|
|
60
60
|
aiValidationEnabled: false,
|
|
61
61
|
aiValidationAutoApprove: true,
|
|
62
62
|
aiValidationAutoDeny: false,
|
|
63
|
+
dashboardEnabled: false,
|
|
64
|
+
dashboardModel: "claude-haiku-4-5",
|
|
65
|
+
dashboardRunHour: 3,
|
|
66
|
+
dashboardMaxSessionsPerRun: 30,
|
|
63
67
|
publicUrl: "",
|
|
64
68
|
updateChannel: "stable",
|
|
65
69
|
dockerAutoUpdate: false,
|
|
@@ -192,6 +196,10 @@ describe("linear-connections", () => {
|
|
|
192
196
|
aiValidationEnabled: false,
|
|
193
197
|
aiValidationAutoApprove: true,
|
|
194
198
|
aiValidationAutoDeny: false,
|
|
199
|
+
dashboardEnabled: false,
|
|
200
|
+
dashboardModel: "claude-haiku-4-5",
|
|
201
|
+
dashboardRunHour: 3,
|
|
202
|
+
dashboardMaxSessionsPerRun: 30,
|
|
195
203
|
publicUrl: "",
|
|
196
204
|
updateChannel: "stable",
|
|
197
205
|
dockerAutoUpdate: false,
|
|
@@ -234,6 +242,10 @@ describe("linear-connections", () => {
|
|
|
234
242
|
aiValidationEnabled: false,
|
|
235
243
|
aiValidationAutoApprove: true,
|
|
236
244
|
aiValidationAutoDeny: false,
|
|
245
|
+
dashboardEnabled: false,
|
|
246
|
+
dashboardModel: "claude-haiku-4-5",
|
|
247
|
+
dashboardRunHour: 3,
|
|
248
|
+
dashboardMaxSessionsPerRun: 30,
|
|
237
249
|
publicUrl: "",
|
|
238
250
|
updateChannel: "stable",
|
|
239
251
|
dockerAutoUpdate: false,
|
|
@@ -295,6 +307,10 @@ describe("linear-connections", () => {
|
|
|
295
307
|
aiValidationEnabled: false,
|
|
296
308
|
aiValidationAutoApprove: true,
|
|
297
309
|
aiValidationAutoDeny: false,
|
|
310
|
+
dashboardEnabled: false,
|
|
311
|
+
dashboardModel: "claude-haiku-4-5",
|
|
312
|
+
dashboardRunHour: 3,
|
|
313
|
+
dashboardMaxSessionsPerRun: 30,
|
|
298
314
|
publicUrl: "",
|
|
299
315
|
updateChannel: "stable",
|
|
300
316
|
dockerAutoUpdate: false,
|