shine-code-submit 0.2.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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/LICENSE +21 -0
  4. package/README.md +217 -0
  5. package/bin/launcher.cjs +35 -0
  6. package/bun.lock +45 -0
  7. package/dist/install.cjs +8 -0
  8. package/hooks/hooks.json +81 -0
  9. package/package.json +68 -0
  10. package/src/cli/main.ts +128 -0
  11. package/src/daemon/auth.ts +7 -0
  12. package/src/daemon/bus.ts +25 -0
  13. package/src/daemon/git.ts +118 -0
  14. package/src/daemon/logger.ts +66 -0
  15. package/src/daemon/main.ts +95 -0
  16. package/src/daemon/server.ts +208 -0
  17. package/src/daemon/spool-consumer.ts +52 -0
  18. package/src/daemon/stats.ts +33 -0
  19. package/src/daemon/store.ts +147 -0
  20. package/src/daemon/token-cache.ts +35 -0
  21. package/src/daemon/transcript.ts +130 -0
  22. package/src/daemon/ui-assets.ts +4 -0
  23. package/src/daemon/ui.ts +34 -0
  24. package/src/daemon/ws.ts +54 -0
  25. package/src/hook/main.ts +154 -0
  26. package/src/install/bun.ts +97 -0
  27. package/src/install/deploy.ts +78 -0
  28. package/src/install/json-safe.ts +43 -0
  29. package/src/install/main.ts +167 -0
  30. package/src/install/paths.ts +28 -0
  31. package/src/install/register.ts +108 -0
  32. package/src/shared/config.ts +77 -0
  33. package/src/shared/daemonctl.ts +110 -0
  34. package/src/shared/id.ts +19 -0
  35. package/src/shared/paths.ts +23 -0
  36. package/src/shared/pidfile.ts +32 -0
  37. package/src/shared/spool.ts +60 -0
  38. package/src/shared/types.ts +116 -0
  39. package/ui/.build/app.js +76 -0
  40. package/ui/app.tsx +31 -0
  41. package/ui/components/App.tsx +55 -0
  42. package/ui/components/CommitsModule.tsx +25 -0
  43. package/ui/components/CommitsView.tsx +84 -0
  44. package/ui/components/Conversation.tsx +101 -0
  45. package/ui/components/DiffBlock.tsx +64 -0
  46. package/ui/components/EventDetail.tsx +8 -0
  47. package/ui/components/EventItem.tsx +24 -0
  48. package/ui/components/EventList.tsx +22 -0
  49. package/ui/components/EventsModule.tsx +123 -0
  50. package/ui/components/Header.tsx +15 -0
  51. package/ui/components/Icon.tsx +114 -0
  52. package/ui/components/Markdown.tsx +13 -0
  53. package/ui/components/Message.tsx +60 -0
  54. package/ui/components/OverviewModule.tsx +86 -0
  55. package/ui/components/SessionDetail.tsx +51 -0
  56. package/ui/components/SessionTree.tsx +94 -0
  57. package/ui/components/SessionsModule.tsx +39 -0
  58. package/ui/components/SessionsPanel.tsx +15 -0
  59. package/ui/components/SideNav.tsx +46 -0
  60. package/ui/components/Splitter.tsx +23 -0
  61. package/ui/components/StatsModule.tsx +133 -0
  62. package/ui/components/Status.tsx +66 -0
  63. package/ui/components/SummaryView.tsx +83 -0
  64. package/ui/components/SystemModule.tsx +38 -0
  65. package/ui/components/ToolCard.tsx +57 -0
  66. package/ui/hooks/useAllCommits.ts +59 -0
  67. package/ui/hooks/useApi.ts +13 -0
  68. package/ui/hooks/useCommits.ts +45 -0
  69. package/ui/hooks/useConversation.ts +43 -0
  70. package/ui/hooks/useEvents.ts +45 -0
  71. package/ui/hooks/useSplitter.ts +43 -0
  72. package/ui/hooks/useStatsPolling.ts +35 -0
  73. package/ui/hooks/useWebSocket.ts +38 -0
  74. package/ui/index.html +13 -0
  75. package/ui/lib/aggregate.ts +81 -0
  76. package/ui/lib/diff.ts +45 -0
  77. package/ui/lib/export.ts +45 -0
  78. package/ui/lib/format.ts +100 -0
  79. package/ui/lib/util.ts +106 -0
  80. package/ui/state/AppContext.tsx +65 -0
  81. package/ui/style.css +849 -0
  82. package/ui/types.ts +46 -0
@@ -0,0 +1,7 @@
1
+ // token 鉴权。token 为 Daemon 启动时生成的随机串,写 pid 文件,Hook/查看页带上。
2
+ import type { PidFile } from "../shared/types";
3
+
4
+ export function checkToken(authHeader: string | null, pid: PidFile): boolean {
5
+ if (!authHeader) return false;
6
+ return authHeader === `Bearer ${pid.token}`;
7
+ }
@@ -0,0 +1,25 @@
1
+ // 进程内事件总线:热路径与回捞入库后都向其 emit,WS 与 stats 订阅。
2
+ import type { HookEvent } from "../shared/types";
3
+
4
+ export type EventListener = (event: HookEvent) => void;
5
+
6
+ export class EventBus {
7
+ private listeners = new Set<EventListener>();
8
+
9
+ on(fn: EventListener): () => void {
10
+ this.listeners.add(fn);
11
+ return () => {
12
+ this.listeners.delete(fn);
13
+ };
14
+ }
15
+
16
+ emit(event: HookEvent): void {
17
+ for (const fn of this.listeners) {
18
+ try {
19
+ fn(event);
20
+ } catch {
21
+ /* 单个订阅者失败不影响其他 */
22
+ }
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,118 @@
1
+ // 在某 cwd 跑 git log 解析为提交列表(查看页「提交」视图用)。
2
+ // 容错优先:非 git 仓库 / git 未装 / 超时 → commits 空数组 + error,绝不抛。
3
+ import { GIT_TIMEOUT_MS } from "../shared/config";
4
+ import type { CommitFile, CommitLog, CommitsResponse } from "../shared/types";
5
+
6
+ const SEP = "\x1f"; // unit separator,分隔 pretty 各字段,避免与 subject 内容冲突
7
+
8
+ /**
9
+ * 拉取最近 N 条非 merge 提交及其 +新增/-删除 行数。
10
+ *
11
+ * git log --numstat --pretty=format:... 的输出形如:
12
+ * <hash>\x1f<iso>\x1f<author>\x1f<subject> ← pretty 行(含 SEP)开启一条 commit
13
+ * <added>\t<deleted>\t<path> ← 0..N 行 numstat,二进制文件为 -\t-\t<path>
14
+ * <hash>\x1f...
15
+ */
16
+ export async function getCommits(cwd: string, limit = 200): Promise<CommitsResponse> {
17
+ const safeLimit = Math.min(Math.max(Math.floor(limit) || 200, 1), 1000);
18
+ let stdout: string;
19
+ try {
20
+ stdout = await runGit(cwd, [
21
+ "log",
22
+ "--no-merges",
23
+ `-n`,
24
+ `${safeLimit}`,
25
+ "--numstat",
26
+ `--pretty=format:%H${SEP}%cI${SEP}%an${SEP}%s`,
27
+ ]);
28
+ } catch (e) {
29
+ return { cwd, commits: [], error: friendlyErr(e) };
30
+ }
31
+ return { cwd, commits: parseLog(stdout) };
32
+ }
33
+
34
+ /** 跑 git 子进程,超时或非 0 退出 reject;stdout 文本 resolve。 */
35
+ function runGit(cwd: string, args: string[]): Promise<string> {
36
+ return new Promise((resolve, reject) => {
37
+ const proc = Bun.spawn({
38
+ cmd: ["git", "-C", cwd, ...args],
39
+ stdout: "pipe",
40
+ stderr: "pipe",
41
+ windowsHide: true,
42
+ });
43
+ const timer = setTimeout(() => {
44
+ proc.kill();
45
+ reject(new Error(`git timed out after ${GIT_TIMEOUT_MS}ms`));
46
+ }, GIT_TIMEOUT_MS);
47
+ void proc.exited
48
+ .then(async (code) => {
49
+ clearTimeout(timer);
50
+ if (code !== 0) {
51
+ const err = await new Response(proc.stderr).text();
52
+ reject(new Error(`git exit ${code}: ${err.trim()}`));
53
+ return;
54
+ }
55
+ resolve(await new Response(proc.stdout).text());
56
+ })
57
+ .catch((e) => {
58
+ clearTimeout(timer);
59
+ reject(e);
60
+ });
61
+ });
62
+ }
63
+
64
+ function parseLog(stdout: string): CommitLog[] {
65
+ const commits: CommitLog[] = [];
66
+ let cur: CommitLog | null = null;
67
+ for (const line of stdout.split("\n")) {
68
+ if (!line) continue;
69
+ if (line.includes(SEP)) {
70
+ if (cur) commits.push(cur);
71
+ const i1 = line.indexOf(SEP);
72
+ const i2 = line.indexOf(SEP, i1 + 1);
73
+ const i3 = line.indexOf(SEP, i2 + 1);
74
+ cur = {
75
+ hash: line.slice(0, i1),
76
+ time: parseIso(line.slice(i1 + 1, i2)),
77
+ author: line.slice(i2 + 1, i3),
78
+ subject: line.slice(i3 + 1),
79
+ files: [],
80
+ added: 0,
81
+ deleted: 0,
82
+ };
83
+ } else if (cur) {
84
+ const f = parseNumstat(line);
85
+ if (f) {
86
+ cur.files.push(f);
87
+ cur.added += f.added;
88
+ cur.deleted += f.deleted;
89
+ }
90
+ }
91
+ }
92
+ if (cur) commits.push(cur);
93
+ return commits;
94
+ }
95
+
96
+ /** numstat 行:<added>\t<deleted>\t<path>;二进制为 -\t-\t<path>(计 0)。 */
97
+ function parseNumstat(line: string): CommitFile | null {
98
+ const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
99
+ if (!m) return null;
100
+ const a = m[1] ?? "-";
101
+ const d = m[2] ?? "-";
102
+ const p = m[3] ?? "";
103
+ if (!p) return null;
104
+ return { path: p, added: a === "-" ? 0 : parseInt(a, 10), deleted: d === "-" ? 0 : parseInt(d, 10) };
105
+ }
106
+
107
+ function parseIso(s: string): number {
108
+ const t = Date.parse(s);
109
+ return Number.isFinite(t) ? t : 0;
110
+ }
111
+
112
+ /** 把底层错误翻译成查看页可直接展示的中文提示。 */
113
+ function friendlyErr(e: unknown): string {
114
+ const s = e instanceof Error ? e.message : String(e);
115
+ if (/not a git repository|fatal:.*repository/i.test(s)) return "当前目录不是 git 仓库";
116
+ if (/ENOENT|spawn|not found|no such file/i.test(s)) return "未找到 git,请确认 git 已安装并在 PATH";
117
+ return s;
118
+ }
@@ -0,0 +1,66 @@
1
+ // 文件日志 + 按大小轮转 + tail。detached Daemon 同时写文件与 stdout
2
+ // (stdout 由启动者重定向到同一文件或控制台)。
3
+ import { appendFileSync, statSync, renameSync, readFileSync, existsSync } from "node:fs";
4
+ import { LOG_FILE } from "../shared/paths";
5
+ import { LOG_ROTATE_BYTES } from "../shared/config";
6
+
7
+ export class Logger {
8
+ constructor(private tag = "daemon") {}
9
+
10
+ private write(level: string, msg: string, extra?: unknown): void {
11
+ const line = `[${localIso()}] [${level}] [${this.tag}] ${msg}${extra !== undefined ? " " + safeStringify(extra) : ""}\n`;
12
+ this.maybeRotate();
13
+ try {
14
+ appendFileSync(LOG_FILE, line);
15
+ } catch {
16
+ /* 磁盘满等:跳过文件,避免抛出影响主流程 */
17
+ }
18
+ process.stdout.write(line);
19
+ }
20
+
21
+ info(m: string, e?: unknown) { this.write("INFO", m, e); }
22
+ warn(m: string, e?: unknown) { this.write("WARN", m, e); }
23
+ error(m: string, e?: unknown) { this.write("ERROR", m, e); }
24
+ debug(m: string, e?: unknown) { if (process.env.SHINE_CODE_SUBMIT_DEBUG) this.write("DEBUG", m, e); }
25
+
26
+ private maybeRotate(): void {
27
+ try {
28
+ if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > LOG_ROTATE_BYTES) {
29
+ renameSync(LOG_FILE, `${LOG_FILE}.${Date.now()}`);
30
+ }
31
+ } catch {
32
+ /* ignore */
33
+ }
34
+ }
35
+
36
+ tail(lines: number): string[] {
37
+ try {
38
+ const raw = readFileSync(LOG_FILE, "utf8");
39
+ return raw.split("\n").filter(Boolean).slice(-lines);
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+ }
45
+
46
+ function safeStringify(v: unknown): string {
47
+ if (v instanceof Error) return JSON.stringify({ name: v.name, message: v.message, stack: v.stack });
48
+ try {
49
+ return JSON.stringify(v);
50
+ } catch {
51
+ return String(v);
52
+ }
53
+ }
54
+
55
+ /** 本地时间 ISO 风格(带本地时区偏移)。toISOString() 恒为 UTC(Z),日志会与本地差 8 小时,故用此。 */
56
+ function localIso(d = new Date()): string {
57
+ const pad = (n: number, w = 2) => String(n).padStart(w, "0");
58
+ const off = -d.getTimezoneOffset(); // 分钟;东八区为 +480
59
+ const sign = off >= 0 ? "+" : "-";
60
+ const abs = Math.abs(off);
61
+ return (
62
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
63
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` +
64
+ `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
65
+ );
66
+ }
@@ -0,0 +1,95 @@
1
+ // Daemon 入口:组装 store/bus/stats/logger/spool/ws/server,写 pid 文件,启动并回捞一次。
2
+ import { ensureDirs } from "../shared/paths";
3
+ import { writePidFile, readPidFile, removePidFile } from "../shared/pidfile";
4
+ import { SERVICE_NAME, SERVICE_VERSION, PORT, SPOOL_SCAN_INTERVAL_MS, LISTEN_HOST } from "../shared/config";
5
+ import { isOursAlive } from "../shared/daemonctl";
6
+ import { Store } from "./store";
7
+ import { EventBus } from "./bus";
8
+ import { Stats } from "./stats";
9
+ import { Logger } from "./logger";
10
+ import { SpoolConsumer } from "./spool-consumer";
11
+ import { WebSocketPool } from "./ws";
12
+ import { startServer } from "./server";
13
+ import { serveUi } from "./ui";
14
+
15
+ async function main(): Promise<void> {
16
+ ensureDirs();
17
+ const log = new Logger("daemon");
18
+
19
+ // 已有自己人的 daemon 在跑 → 复用,不重复启动。
20
+ // 防止 hook/CLI 因瞬时探活失败重复拉起时,第二个实例端口冲突 crash 并误删 pid 文件。
21
+ if (await isOursAlive()) {
22
+ log.info("another shine-code-submit daemon already running; exit without starting");
23
+ process.exit(0);
24
+ }
25
+
26
+ const existing = readPidFile();
27
+ if (existing) {
28
+ log.warn("stale pid file on startup", existing);
29
+ }
30
+
31
+ const startedAt = Date.now();
32
+ const pid = {
33
+ pid: process.pid,
34
+ port: PORT,
35
+ token: crypto.randomUUID(),
36
+ startedAt,
37
+ };
38
+ const store = new Store();
39
+ const bus = new EventBus();
40
+ const stats = new Stats();
41
+ const spool = new SpoolConsumer(store, bus, stats, log);
42
+ const wsPool = new WebSocketPool(bus, stats);
43
+
44
+ // 启动即回捞(处理上次崩溃遗留的 spool)
45
+ const recovered = spool.drain();
46
+ if (recovered > 0) log.info(`recovered ${recovered} events from spool on startup`);
47
+ spool.start(SPOOL_SCAN_INTERVAL_MS);
48
+ wsPool.attach();
49
+
50
+ // 只清理属于自己的 pid 文件(防止误删他人)
51
+ const ownsPidFile = (): boolean => readPidFile()?.pid === process.pid;
52
+
53
+ let server: ReturnType<typeof startServer>;
54
+ const shutdown = (reason: string) => {
55
+ log.info(`shutdown: ${reason}`);
56
+ try { wsPool.dispose(); } catch { /* noop */ }
57
+ try { server.stop(true); } catch { /* noop */ }
58
+ try { store.close(); } catch { /* noop */ }
59
+ if (ownsPidFile()) removePidFile();
60
+ process.exit(0);
61
+ };
62
+ process.on("SIGINT", () => shutdown("SIGINT"));
63
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
64
+ process.on("exit", () => {
65
+ if (ownsPidFile()) removePidFile();
66
+ try { store.close(); } catch { /* noop */ }
67
+ });
68
+
69
+ server = startServer({
70
+ pid,
71
+ startedAt,
72
+ store,
73
+ bus,
74
+ stats,
75
+ log,
76
+ serveUi,
77
+ onWsOpen: (ws) => wsPool.add(ws),
78
+ onWsClose: (ws) => wsPool.remove(ws),
79
+ shutdown: () => shutdown("api"),
80
+ });
81
+
82
+ // 端口绑定成功后才写 pid 文件:hook 与 cli 可能并发拉起 daemon,isOursAlive 存在竞态,
83
+ // 若先写 pid 文件再 bind,bind 失败的实例会覆盖/删除 pid 文件,导致 listening 实例与 pid 文件
84
+ // 不一致(cli stop/restart/ui 据此取 token 会失效)。bind 成功才意味着本实例胜出。
85
+ writePidFile(pid);
86
+
87
+ log.info(`${SERVICE_NAME} v${SERVICE_VERSION} listening http://${LISTEN_HOST}:${PORT} pid=${process.pid} token=${pid.token}`);
88
+ }
89
+
90
+ try {
91
+ await main();
92
+ } catch (err) {
93
+ console.error("fatal:", err);
94
+ process.exit(1);
95
+ }
@@ -0,0 +1,208 @@
1
+ // HTTP/WS 路由与鉴权。组装 Bun.serve。
2
+ // 健康端点与静态页不鉴权;其余端点(事件接收、stats、events、sessions、ws、shutdown)需 token。
3
+ import type { ServerWebSocket } from "bun";
4
+ import { LISTEN_HOST, PORT, SERVICE_NAME, SERVICE_VERSION, LOG_TAIL_LINES, SESSION_TOKEN_ENRICH_LIMIT } from "../shared/config";
5
+ import type { HookEvent, HookEventType, PidFile } from "../shared/types";
6
+ import { deriveStableEventId } from "../shared/id";
7
+ import { checkToken } from "./auth";
8
+ import { parseTranscript, sumUsage } from "./transcript";
9
+ import { getSessionTokenTotal } from "./token-cache";
10
+ import { getCommits } from "./git";
11
+ import type { Store } from "./store";
12
+ import type { EventBus } from "./bus";
13
+ import type { Stats } from "./stats";
14
+ import type { Logger } from "./logger";
15
+
16
+ export interface ServerDeps {
17
+ pid: PidFile;
18
+ startedAt: number;
19
+ store: Store;
20
+ bus: EventBus;
21
+ stats: Stats;
22
+ log: Logger;
23
+ serveUi: (req: Request, url: URL) => Response | Promise<Response>;
24
+ onWsOpen?: (ws: ServerWebSocket<unknown>) => void;
25
+ onWsClose?: (ws: ServerWebSocket<unknown>) => void;
26
+ shutdown: () => void;
27
+ }
28
+
29
+ export function startServer(deps: ServerDeps) {
30
+ const { pid, store, bus, stats, log } = deps;
31
+
32
+ const authed = (req: Request) => checkToken(req.headers.get("authorization"), pid);
33
+
34
+ const json = (body: unknown, status = 200) =>
35
+ new Response(JSON.stringify(body), {
36
+ status,
37
+ headers: { "content-type": "application/json; charset=utf-8" },
38
+ });
39
+
40
+ return Bun.serve({
41
+ hostname: LISTEN_HOST,
42
+ port: PORT,
43
+ async fetch(req, server) {
44
+ const url = new URL(req.url);
45
+ const path = url.pathname;
46
+
47
+ // ---- health(无鉴权):Hook「认自己人」用 ----
48
+ if (path === "/api/health" && req.method === "GET") {
49
+ return json({
50
+ service: SERVICE_NAME,
51
+ version: SERVICE_VERSION,
52
+ pid: pid.pid,
53
+ uptime: Date.now() - deps.startedAt,
54
+ });
55
+ }
56
+
57
+ // ---- 静态页(无鉴权;数据接口仍鉴权)----
58
+ if (path === "/" || path === "/ui" || path.startsWith("/ui/")) {
59
+ return await deps.serveUi(req, url);
60
+ }
61
+
62
+ // ---- WS 升级(鉴权;浏览器无法设 header,故支持 ?t= 查询参数)----
63
+ if (path === "/api/ws" && req.method === "GET") {
64
+ const q = url.searchParams.get("t");
65
+ const authHeader = q ? `Bearer ${q}` : req.headers.get("authorization");
66
+ if (!checkToken(authHeader, pid)) return json({ error: "unauthorized" }, 401);
67
+ if (server.upgrade(req, { data: { tokenOk: true } })) {
68
+ return new Response(null, { status: 101 });
69
+ }
70
+ return json({ error: "upgrade failed" }, 400);
71
+ }
72
+
73
+ // ---- 以下均需鉴权 ----
74
+ if (!authed(req)) return json({ error: "unauthorized" }, 401);
75
+
76
+ // 事件接收(热路径)
77
+ const m = path.match(/^\/api\/hook\/(\w+)$/);
78
+ if (m && req.method === "POST") {
79
+ const type = m[1] as HookEventType;
80
+ let body: unknown;
81
+ try {
82
+ body = await req.json();
83
+ } catch {
84
+ return json({ error: "bad json" }, 400);
85
+ }
86
+ const event = normalizeEvent(type, body);
87
+ if (!event) return json({ error: "missing required fields (cwd, sessionId)" }, 400);
88
+ const inserted = store.insert(event);
89
+ if (inserted) {
90
+ bus.emit(event);
91
+ stats.recordEvent();
92
+ log.info(`ingest http ${event.type}`);
93
+ }
94
+ return json({ status: "ok", inserted });
95
+ }
96
+
97
+ if (path === "/api/stats" && req.method === "GET") {
98
+ return json({
99
+ service: SERVICE_NAME,
100
+ version: SERVICE_VERSION,
101
+ pid: pid.pid,
102
+ uptime: Date.now() - deps.startedAt,
103
+ spoolBacklog: stats.backlog(),
104
+ eventsPerSec: stats.rate(),
105
+ totalEvents: store.count(),
106
+ lastError: stats.lastError,
107
+ logTail: log.tail(LOG_TAIL_LINES),
108
+ });
109
+ }
110
+
111
+ if (path === "/api/events" && req.method === "GET") {
112
+ const sp = url.searchParams;
113
+ return json({
114
+ events: store.query({
115
+ cwd: sp.get("cwd") ?? undefined,
116
+ sessionId: sp.get("sessionId") ?? undefined,
117
+ type: sp.get("type") ?? undefined,
118
+ since: num(sp.get("since")),
119
+ limit: num(sp.get("limit")) ?? 200,
120
+ }),
121
+ });
122
+ }
123
+
124
+ if (path === "/api/sessions" && req.method === "GET") {
125
+ const sessions = store.sessions();
126
+ // 仅对最近 N 个 session enrich tokenTotal(读 transcript 较重,走 mtime 缓存);
127
+ // 更老的留 undefined,避免每 2s 轮询时重读大量旧 transcript。
128
+ for (let i = 0; i < Math.min(sessions.length, SESSION_TOKEN_ENRICH_LIMIT); i++) {
129
+ const s = sessions[i];
130
+ if (!s) continue;
131
+ const tp = findTranscriptPath(store, s.sessionId);
132
+ s.tokenTotal = tp ? getSessionTokenTotal(tp) : null;
133
+ }
134
+ return json({ sessions });
135
+ }
136
+
137
+ // 对话视图:从该 session 任一事件的 payload.transcript_path 读完整 transcript
138
+ if (path === "/api/transcript" && req.method === "GET") {
139
+ const sessionId = url.searchParams.get("sessionId");
140
+ if (!sessionId) return json({ error: "missing sessionId" }, 400);
141
+ const tp = findTranscriptPath(store, sessionId);
142
+ if (!tp) return json({ error: "no transcript_path found for session" }, 404);
143
+ try {
144
+ const messages = parseTranscript(tp);
145
+ return json({ transcriptPath: tp, messages, tokenTotal: sumUsage(messages) });
146
+ } catch (err) {
147
+ return json({ error: "read transcript failed", detail: String(err) }, 500);
148
+ }
149
+ }
150
+
151
+ // 提交视图:在某 cwd 跑 git log 取最近提交 + 行数(容错,非 git 目录返回空 + error)
152
+ if (path === "/api/commits" && req.method === "GET") {
153
+ const cwd = url.searchParams.get("cwd");
154
+ if (!cwd) return json({ error: "missing cwd" }, 400);
155
+ const limit = num(url.searchParams.get("limit")) ?? 200;
156
+ return json(await getCommits(cwd, limit));
157
+ }
158
+
159
+ if (path === "/api/shutdown" && req.method === "POST") {
160
+ log.info("shutdown requested via api");
161
+ setTimeout(() => deps.shutdown(), 50); // 先响应再退
162
+ return json({ status: "shutting down" });
163
+ }
164
+
165
+ return json({ error: "not found" }, 404);
166
+ },
167
+ websocket: {
168
+ open: (ws: ServerWebSocket<unknown>) => deps.onWsOpen?.(ws),
169
+ message: () => {
170
+ /* 查看页不发消息 */
171
+ },
172
+ close: (ws: ServerWebSocket<unknown>) => deps.onWsClose?.(ws),
173
+ },
174
+ });
175
+ }
176
+
177
+ function num(v: string | null): number | undefined {
178
+ if (v == null) return undefined;
179
+ const n = Number(v);
180
+ return Number.isFinite(n) ? n : undefined;
181
+ }
182
+
183
+ /** 从某 session 的事件 payload 里找 transcript_path(取最近 50 条里第一个带值的)。 */
184
+ function findTranscriptPath(store: Store, sessionId: string): string | null {
185
+ for (const e of store.query({ sessionId, limit: 50 })) {
186
+ const p = e.payload as Record<string, unknown> | null;
187
+ if (p && typeof p.transcript_path === "string") return p.transcript_path;
188
+ }
189
+ return null;
190
+ }
191
+
192
+ function normalizeEvent(type: HookEventType, body: unknown): HookEvent | null {
193
+ if (typeof body !== "object" || body === null) return null;
194
+ const b = body as Record<string, unknown>;
195
+ const cwd = typeof b.cwd === "string" ? b.cwd : "";
196
+ const sessionId = typeof b.sessionId === "string" ? b.sessionId : "";
197
+ if (!cwd || !sessionId) return null;
198
+ const payload = "payload" in b ? b.payload : b;
199
+ return {
200
+ eventId: deriveStableEventId({ type, sessionId, payload }), // 内容派生,保证多路采集幂等
201
+ type,
202
+ timestamp: typeof b.timestamp === "number" ? b.timestamp : Date.now(),
203
+ cwd,
204
+ sessionId,
205
+ pid: typeof b.pid === "number" ? b.pid : 0,
206
+ payload,
207
+ };
208
+ }
@@ -0,0 +1,52 @@
1
+ // spool 消费器(回捞路径):扫目录 → 幂等入库 → 推总线 → 删除(删除即确认)。
2
+ // 与 HTTP 热路径共享 store.insert 幂等逻辑,互为兜底。
3
+ import { listSpool, removeSpoolFile } from "../shared/spool";
4
+ import type { Store } from "./store";
5
+ import type { EventBus } from "./bus";
6
+ import type { Stats } from "./stats";
7
+ import type { Logger } from "./logger";
8
+
9
+ export class SpoolConsumer {
10
+ constructor(
11
+ private store: Store,
12
+ private bus: EventBus,
13
+ private stats: Stats,
14
+ private log: Logger,
15
+ ) {}
16
+
17
+ /** 扫描并消费全部 spool。返回本次新入库条数。 */
18
+ drain(): number {
19
+ const entries = listSpool((name, err) =>
20
+ this.log.warn(`spool corrupt, skipping ${name}`, err),
21
+ );
22
+ let n = 0;
23
+ for (const { name, event } of entries) {
24
+ try {
25
+ if (this.store.insert(event)) {
26
+ this.bus.emit(event);
27
+ this.stats.recordEvent();
28
+ n++;
29
+ this.log.info(`ingest spool ${event.type} ${name}`);
30
+ } else {
31
+ this.log.debug(`dedup spool ${name}`);
32
+ }
33
+ } catch (err) {
34
+ this.stats.recordError(`spool ingest failed: ${name}`);
35
+ this.log.error(`spool ingest failed ${name}`, err);
36
+ }
37
+ removeSpoolFile(name); // 无论新插入还是去重,都已确认
38
+ }
39
+ return n;
40
+ }
41
+
42
+ start(intervalMs: number): void {
43
+ const tick = () => {
44
+ try {
45
+ this.drain();
46
+ } catch (err) {
47
+ this.log.error("spool tick failed", err);
48
+ }
49
+ };
50
+ setInterval(tick, intervalMs);
51
+ }
52
+ }
@@ -0,0 +1,33 @@
1
+ // 运行指标:滑动窗口算 events/sec、累计总数、最近错误、spool 积压。
2
+ import { STATS_WINDOW_MS } from "../shared/config";
3
+ import { countSpool } from "../shared/spool";
4
+
5
+ export class Stats {
6
+ private stamps: number[] = [];
7
+ total = 0;
8
+ lastError: { time: number; message: string } | null = null;
9
+
10
+ recordEvent(now = Date.now()): void {
11
+ this.stamps.push(now);
12
+ this.total++;
13
+ this.gc(now);
14
+ }
15
+
16
+ recordError(message: string, now = Date.now()): void {
17
+ this.lastError = { time: now, message };
18
+ }
19
+
20
+ private gc(now: number): void {
21
+ const cutoff = now - STATS_WINDOW_MS;
22
+ while (this.stamps.length && (this.stamps[0] ?? 0) < cutoff) this.stamps.shift();
23
+ }
24
+
25
+ rate(now = Date.now()): number {
26
+ this.gc(now);
27
+ return this.stamps.length / (STATS_WINDOW_MS / 1000);
28
+ }
29
+
30
+ backlog(): number {
31
+ return countSpool();
32
+ }
33
+ }