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,108 @@
1
+ // 注册 plugin 到三处 JSON:known_marketplaces + installed_plugins + settings.enabledPlugins。
2
+ // 关键:directory marketplace 的 plugin 不会自动进 settings.enabledPlugins(bug #17832),
3
+ // 必须 install 脚本显式写 settings.json,否则 plugin 文件在但 hook 不生效。
4
+ import { join } from "node:path";
5
+ import { readJsonDefault, writeJsonAtomicWithBackup } from "./json-safe";
6
+ import { installedPluginsPath, knownMarketplacesPath, pluginsRoot, settingsPath } from "./paths";
7
+ import { MARKETPLACE_NAME, PLUGIN_NAME } from "./deploy";
8
+ import { SERVICE_VERSION } from "../shared/config";
9
+
10
+ /* eslint-disable @typescript-eslint/no-explicit-any -- claude 的 JSON 结构是动态的,用 any 最直接 */
11
+
12
+ function pluginKey(): string {
13
+ return `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
14
+ }
15
+
16
+ /** 注册 marketplace(directory source,指向 cachePath/.claude-plugin)。幂等。 */
17
+ export function registerMarketplace(cachePath: string): void {
18
+ const file = knownMarketplacesPath();
19
+ const data = readJsonDefault<Record<string, any>>(file, {});
20
+ const marketplaceDir = join(cachePath, ".claude-plugin");
21
+ const existing = data[MARKETPLACE_NAME];
22
+ if (existing?.source && existing.source.source !== "directory") {
23
+ console.log(
24
+ `[shine-code-submit] WARNING: marketplace "${MARKETPLACE_NAME}" 已存在(source=${existing.source.source}),将覆盖为 directory 源(原文件已备份)`,
25
+ );
26
+ }
27
+ data[MARKETPLACE_NAME] = {
28
+ source: { source: "directory", path: marketplaceDir },
29
+ installLocation: join(pluginsRoot(), "marketplaces", MARKETPLACE_NAME),
30
+ lastUpdated: new Date().toISOString(),
31
+ autoUpdate: false,
32
+ };
33
+ writeJsonAtomicWithBackup(file, data);
34
+ console.log(`[shine-code-submit] marketplace 已注册 → ${file}`);
35
+ }
36
+
37
+ /** 注册 plugin 到 installed_plugins.json(version 2 结构)。幂等。 */
38
+ export function registerPlugin(cachePath: string): void {
39
+ const file = installedPluginsPath();
40
+ const data = readJsonDefault<{ version?: number; plugins?: Record<string, any[]> }>(file, {
41
+ version: 2,
42
+ plugins: {},
43
+ });
44
+ if (!data.version) data.version = 2;
45
+ if (!data.plugins) data.plugins = {};
46
+ const key = pluginKey();
47
+ const now = new Date().toISOString();
48
+ const existing = data.plugins[key]?.[0];
49
+ data.plugins[key] = [
50
+ {
51
+ scope: "user",
52
+ installPath: cachePath,
53
+ version: SERVICE_VERSION,
54
+ installedAt: existing?.installedAt ?? now,
55
+ lastUpdated: now,
56
+ },
57
+ ];
58
+ writeJsonAtomicWithBackup(file, data);
59
+ console.log(`[shine-code-submit] plugin 已注册 → ${file}`);
60
+ }
61
+
62
+ /** 启用 plugin:写 settings.json 的 enabledPlugins + extraKnownMarketplaces。幂等。解 #17832。 */
63
+ export function enablePlugin(cachePath: string): void {
64
+ const file = settingsPath();
65
+ const data = readJsonDefault<Record<string, any>>(file, {});
66
+ if (!data.enabledPlugins) data.enabledPlugins = {};
67
+ const key = pluginKey();
68
+ data.enabledPlugins[key] = true;
69
+
70
+ if (!data.extraKnownMarketplaces) data.extraKnownMarketplaces = {};
71
+ if (!data.extraKnownMarketplaces[MARKETPLACE_NAME]) {
72
+ data.extraKnownMarketplaces[MARKETPLACE_NAME] = {
73
+ source: { source: "directory", path: join(cachePath, ".claude-plugin") },
74
+ };
75
+ }
76
+ writeJsonAtomicWithBackup(file, data);
77
+ console.log(`[shine-code-submit] 已启用(enabledPlugins)→ ${file}`);
78
+ }
79
+
80
+ /** 反注册:从三处 JSON 移除条目。幂等。 */
81
+ export function unregisterAll(): void {
82
+ const key = pluginKey();
83
+
84
+ const km = readJsonDefault<Record<string, any>>(knownMarketplacesPath(), {});
85
+ if (km[MARKETPLACE_NAME]) {
86
+ delete km[MARKETPLACE_NAME];
87
+ writeJsonAtomicWithBackup(knownMarketplacesPath(), km);
88
+ }
89
+
90
+ const ip = readJsonDefault<{ plugins?: Record<string, any[]> }>(installedPluginsPath(), { plugins: {} });
91
+ if (ip.plugins && ip.plugins[key]) {
92
+ delete ip.plugins[key];
93
+ writeJsonAtomicWithBackup(installedPluginsPath(), ip);
94
+ }
95
+
96
+ const s = readJsonDefault<Record<string, any>>(settingsPath(), {});
97
+ let changed = false;
98
+ if (s.enabledPlugins && s.enabledPlugins[key]) {
99
+ delete s.enabledPlugins[key];
100
+ changed = true;
101
+ }
102
+ if (s.extraKnownMarketplaces && s.extraKnownMarketplaces[MARKETPLACE_NAME]) {
103
+ delete s.extraKnownMarketplaces[MARKETPLACE_NAME];
104
+ changed = true;
105
+ }
106
+ if (changed) writeJsonAtomicWithBackup(settingsPath(), s);
107
+ console.log("[shine-code-submit] 已从三处 JSON 移除注册");
108
+ }
@@ -0,0 +1,77 @@
1
+ // 全局常量:端口、超时、扫描间隔、服务标识。
2
+ // 集中于此,便于 Hook / Daemon / CLI 三端一致。
3
+ import pkg from "../../package.json";
4
+ import { networkInterfaces } from "node:os";
5
+
6
+ export const SERVICE_NAME = "shine-code-submit";
7
+ export const SERVICE_VERSION = pkg.version; // 单一来源:package.json,避免三处手动同步漏改
8
+
9
+ /**
10
+ * daemon 监听地址。默认 0.0.0.0(绑所有网卡,局域网/其他设备可直接访问)。
11
+ * 仅本机回环用时设 SHINE_CODE_SUBMIT_HOST=127.0.0.1。
12
+ * 注意:/api/health 与 /ui 无鉴权,绑非回环后 token 是数据接口唯一防线,勿在不可信网络下暴露。
13
+ */
14
+ export const LISTEN_HOST = process.env.SHINE_CODE_SUBMIT_HOST ?? "0.0.0.0";
15
+
16
+ export const HOST = "127.0.0.1"; // hook/cli/daemonctl 连接 daemon 用,固定回环(daemon 即使绑 0.0.0.0 也含 127.0.0.1)
17
+ export const PORT = 36666;
18
+ export const BASE_URL = `http://${HOST}:${PORT}`; // 内部访问(hook POST、cli、探活)走 127.0.0.1
19
+
20
+ /**
21
+ * 第一个非回环、非虚拟网卡的 IPv4(给用户展示真实局域网可访问的链接)。
22
+ * 跳过 vEthernet(Hyper-V/WSL)、VMware、VirtualBox、docker/veth/br- 等虚拟网卡,
23
+ * 取真实网卡(以太网/Wi-Fi)的 IP;都没有则退回第一个非回环 IPv4;再没有则 localhost。
24
+ */
25
+ function getPrimaryIpv4(): string {
26
+ const VIRTUAL = ["vethernet", "vmware", "virtualbox", "docker", "veth", "br-", "virbr", "vnet", "utun"];
27
+ const isVirtual = (name: string): boolean => {
28
+ const n = name.toLowerCase();
29
+ return VIRTUAL.some((k) => n.includes(k));
30
+ };
31
+ try {
32
+ const nets = networkInterfaces();
33
+ // 第一轮:跳过回环 + 虚拟网卡,取真实局域网 IP
34
+ for (const name of Object.keys(nets)) {
35
+ if (isVirtual(name)) continue;
36
+ for (const net of nets[name] ?? []) {
37
+ if (net.family === "IPv4" && !net.internal) return net.address;
38
+ }
39
+ }
40
+ // 第二轮:全是虚拟网卡时,退回第一个非回环 IPv4
41
+ for (const name of Object.keys(nets)) {
42
+ for (const net of nets[name] ?? []) {
43
+ if (net.family === "IPv4" && !net.internal) return net.address;
44
+ }
45
+ }
46
+ } catch {
47
+ /* fallthrough to localhost */
48
+ }
49
+ return "localhost";
50
+ }
51
+
52
+ export const PUBLIC_BASE_URL = `http://${getPrimaryIpv4()}:${PORT}`; // 打印给用户/局域网访问的链接(网卡 IP)
53
+
54
+ // Hook 热转发超时:localhost 上 500ms 足够;超时即放弃热路径,靠 spool 兜底。
55
+ export const HOOK_POST_TIMEOUT_MS = 500;
56
+
57
+ // 故障路径:detached 拉起 Daemon 后轮询 /api/health 的总预算与间隔。
58
+ export const HEALTH_POLL_TIMEOUT_MS = 5000;
59
+ export const HEALTH_POLL_INTERVAL_MS = 100;
60
+
61
+ // Daemon 回捞 spool 的扫描间隔。
62
+ export const SPOOL_SCAN_INTERVAL_MS = 1000;
63
+
64
+ // 运行指标窗口(用于计算 events/sec)。
65
+ export const STATS_WINDOW_MS = 10_000;
66
+
67
+ // /api/stats 附带的日志尾行数。
68
+ export const LOG_TAIL_LINES = 200;
69
+
70
+ // 日志按天轮转:超过此大小(字节)也触发轮转。
71
+ export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
72
+
73
+ // git log 子进程超时(提交视图 /api/commits);超时返回空 + error,不阻塞查看页。
74
+ export const GIT_TIMEOUT_MS = 5000;
75
+
76
+ // /api/sessions enrich tokenTotal 时,最多对最近多少个 session 读 transcript 汇总(控 2s 轮询成本)。
77
+ export const SESSION_TOKEN_ENRICH_LIMIT = 50;
@@ -0,0 +1,110 @@
1
+ // 跨进程 daemon 控制:探活(认自己人)、拉起、等待 ready、开浏览器。
2
+ // Hook 与 CLI 共用。
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { BASE_URL, HEALTH_POLL_TIMEOUT_MS, HEALTH_POLL_INTERVAL_MS, SERVICE_NAME } from "./config";
8
+
9
+ /** 探活 + 认自己人:service 字段必须匹配,防端口被无关程序占用误判。 */
10
+ export async function isOursAlive(timeoutMs = 400): Promise<boolean> {
11
+ try {
12
+ const res = await fetch(`${BASE_URL}/api/health`, { signal: AbortSignal.timeout(timeoutMs) });
13
+ if (!res.ok) return false;
14
+ const data = (await res.json()) as { service?: string };
15
+ return data?.service === SERVICE_NAME;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * detached 拉起 daemon。
23
+ * 优先级:env 覆盖 > 同目录 daemon 二进制(二进制模式)> bun run src/daemon/main.ts(源码模式)。
24
+ * 开发期可用 env 覆盖:
25
+ * SHINE_CODE_SUBMIT_DAEMON_CMD 完整命令(shell 执行)
26
+ * SHINE_CODE_SUBMIT_DAEMON 仅 bun run 入口路径
27
+ */
28
+ export function spawnDaemon(): void {
29
+ const cmd = process.env.SHINE_CODE_SUBMIT_DAEMON_CMD;
30
+ const dir = dirname(process.execPath);
31
+ const ext = process.platform === "win32" ? ".exe" : "";
32
+ const daemonBin = join(dir, `daemon${ext}`);
33
+ try {
34
+ if (cmd) {
35
+ spawn(cmd, { detached: true, stdio: "ignore", windowsHide: true, shell: true }).unref();
36
+ } else if (process.env.SHINE_CODE_SUBMIT_DAEMON) {
37
+ spawn(process.execPath, ["run", process.env.SHINE_CODE_SUBMIT_DAEMON], {
38
+ detached: true,
39
+ stdio: "ignore",
40
+ windowsHide: true,
41
+ }).unref();
42
+ } else if (existsSync(daemonBin)) {
43
+ // 二进制模式:与当前 exe 同目录的 daemon 二进制
44
+ spawn(daemonBin, [], { detached: true, stdio: "ignore", windowsHide: true }).unref();
45
+ } else {
46
+ // 源码模式:bun run src/daemon/main.ts(本文件在 src/shared/,相对定位)
47
+ const here = dirname(fileURLToPath(import.meta.url));
48
+ const daemonSrc = join(here, "..", "daemon", "main.ts");
49
+ // 用 process.execPath(hook/cli 由 bun 跑时即 bun.exe 完整路径),不靠 PATH/PATHEXT 解析——
50
+ // Windows 上 bun 进程内 spawn("bun") 不查 PATHEXT 会 ENOENT(Linux 无此问题)
51
+ spawn(process.execPath, ["run", daemonSrc], { detached: true, stdio: "ignore", windowsHide: true }).unref();
52
+ }
53
+ } catch (err) {
54
+ process.stderr.write(`[shine-code-submit] spawn daemon failed: ${safeMsg(err)}\n`);
55
+ }
56
+ }
57
+
58
+ /** 确保 daemon 就绪:不在则拉起并轮询至 ready(或超时)。 */
59
+ export async function ensureDaemon(): Promise<boolean> {
60
+ if (await isOursAlive()) return true;
61
+ spawnDaemon();
62
+ const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS;
63
+ while (Date.now() < deadline) {
64
+ await sleep(HEALTH_POLL_INTERVAL_MS);
65
+ if (await isOursAlive()) return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ /** 跨平台打开浏览器。WSL 走 Windows interop(cmd.exe start)比 xdg-open 稳。 */
71
+ export function openBrowser(url: string): void {
72
+ const platform = process.platform;
73
+ let cmd: string;
74
+ let args: string[];
75
+ if (platform === "win32") {
76
+ cmd = "cmd";
77
+ args = ["/c", "start", "", url];
78
+ } else if (platform === "darwin") {
79
+ cmd = "open";
80
+ args = [url];
81
+ } else if (isWsl()) {
82
+ cmd = "cmd.exe";
83
+ args = ["/c", "start", "", url];
84
+ } else {
85
+ cmd = "xdg-open";
86
+ args = [url];
87
+ }
88
+ try {
89
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
90
+ } catch {
91
+ /* ignore */
92
+ }
93
+ }
94
+
95
+ /** 是否跑在 WSL(Linux 内核版本字符串含 microsoft)。用于 openBrowser 选 interop 路径。 */
96
+ function isWsl(): boolean {
97
+ if (process.platform !== "linux") return false;
98
+ try {
99
+ return readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft");
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function sleep(ms: number): Promise<void> {
106
+ return new Promise((r) => setTimeout(r, ms));
107
+ }
108
+ function safeMsg(v: unknown): string {
109
+ return v instanceof Error ? `${v.name}: ${v.message}` : String(v);
110
+ }
@@ -0,0 +1,19 @@
1
+ // 稳定事件 id:基于事件内容派生(而非 hook 进程随机生成),用于幂等去重。
2
+ // 同一语义事件即使被多个 hook 进程采集(如 settings.json + plugin 双注册,
3
+ // 或他人全局 hook 与本 plugin 并存),只要 sessionId/type/payload 相同,
4
+ // 派生出的 eventId 就相同,store 的 PRIMARY KEY (session_id, event_id) 即可去重。
5
+ // 不含 timestamp——两个采集进程的时间戳差几十 ms,跨秒边界会让派生值错开而漏去重。
6
+ import { createHash } from "node:crypto";
7
+ import type { HookEvent } from "./types";
8
+
9
+ export function deriveStableEventId(
10
+ ev: Pick<HookEvent, "type" | "sessionId" | "payload">,
11
+ ): string {
12
+ const h = createHash("sha1");
13
+ h.update(ev.type);
14
+ h.update("\x00");
15
+ h.update(ev.sessionId);
16
+ h.update("\x00");
17
+ h.update(JSON.stringify(ev.payload ?? null));
18
+ return h.digest("hex");
19
+ }
@@ -0,0 +1,23 @@
1
+ // 数据目录布局:%LOCALAPPDATA%/shine-code-submit/{spool,log,db} + daemon.pid
2
+ import { join } from "node:path";
3
+ import { mkdirSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+
6
+ const LOCAL =
7
+ process.env.LOCALAPPDATA ?? join(homedir(), ".local", "share");
8
+
9
+ export const DATA_DIR = join(LOCAL, "shine-code-submit");
10
+ export const SPOOL_DIR = join(DATA_DIR, "spool");
11
+ export const LOG_DIR = join(DATA_DIR, "log");
12
+ export const DB_DIR = join(DATA_DIR, "db");
13
+
14
+ export const PID_FILE = join(DATA_DIR, "daemon.pid");
15
+ export const LOG_FILE = join(LOG_DIR, "daemon.log");
16
+ export const DB_FILE = join(DB_DIR, "events.sqlite");
17
+
18
+ /** 创建所有需要的目录。Daemon 与 Hook 启动时各调一次幂等。 */
19
+ export function ensureDirs(): void {
20
+ for (const d of [DATA_DIR, SPOOL_DIR, LOG_DIR, DB_DIR]) {
21
+ mkdirSync(d, { recursive: true });
22
+ }
23
+ }
@@ -0,0 +1,32 @@
1
+ // pid 文件读写:记录 Daemon pid/port/token/startedAt。
2
+ // Hook 与 CLI 读取 token 用于鉴权;Daemon 写入。
3
+ import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
4
+ import { PID_FILE } from "./paths";
5
+ import type { PidFile } from "./types";
6
+
7
+ export function writePidFile(data: PidFile): void {
8
+ // mode 在 Windows 上不生效,POSIX 下收紧到 0600(仅当前用户可读)。
9
+ writeFileSync(PID_FILE, JSON.stringify(data), { mode: 0o600 });
10
+ }
11
+
12
+ export function readPidFile(): PidFile | null {
13
+ try {
14
+ const raw = readFileSync(PID_FILE, "utf8");
15
+ return JSON.parse(raw) as PidFile;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function removePidFile(): void {
22
+ try {
23
+ unlinkSync(PID_FILE);
24
+ } catch {
25
+ /* 已不存在 */
26
+ }
27
+ }
28
+
29
+ /** Hook / CLI 取鉴权 token 的便捷封装。 */
30
+ export function readToken(): string | null {
31
+ return readPidFile()?.token ?? null;
32
+ }
@@ -0,0 +1,60 @@
1
+ // 目录式 spool:每事件一文件,规避多进程并发 append 的交错/截断。
2
+ // 写:tmp + rename(同分区原子)。读:扫目录。确认:unlink。
3
+ import { renameSync, writeFileSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { SPOOL_DIR } from "./paths";
6
+ import type { HookEvent } from "./types";
7
+
8
+ /** 生成天然唯一的 spool 文件名:{ts}-{pid}-{rand}.json。 */
9
+ export function spoolFileName(ev: HookEvent): string {
10
+ const rand = crypto.randomUUID().slice(0, 8);
11
+ return `${ev.timestamp}-${ev.pid}-${rand}.json`;
12
+ }
13
+
14
+ /** 原子写入一个事件到 spool 目录,返回文件名。失败会抛出(由调用方兜底)。 */
15
+ export function writeSpoolFile(ev: HookEvent): string {
16
+ const name = spoolFileName(ev);
17
+ const final = join(SPOOL_DIR, name);
18
+ const tmp = `${final}.tmp`;
19
+ writeFileSync(tmp, JSON.stringify(ev));
20
+ renameSync(tmp, final); // 同分区原子
21
+ return name;
22
+ }
23
+
24
+ export interface SpoolEntry {
25
+ name: string;
26
+ event: HookEvent;
27
+ }
28
+
29
+ /** 列出 spool 中所有事件,按时间戳升序(回捞按序处理)。解析失败的条目跳过并记录。 */
30
+ export function listSpool(onCorrupt?: (name: string, err: unknown) => void): SpoolEntry[] {
31
+ const names = readdirSync(SPOOL_DIR).filter((f) => f.endsWith(".json"));
32
+ const out: SpoolEntry[] = [];
33
+ for (const name of names) {
34
+ try {
35
+ const event = JSON.parse(readFileSync(join(SPOOL_DIR, name), "utf8")) as HookEvent;
36
+ out.push({ name, event });
37
+ } catch (err) {
38
+ onCorrupt?.(name, err);
39
+ }
40
+ }
41
+ return out.sort((a, b) => a.event.timestamp - b.event.timestamp);
42
+ }
43
+
44
+ /** 处理完成后删除 spool 文件(删除即确认)。 */
45
+ export function removeSpoolFile(name: string): void {
46
+ try {
47
+ unlinkSync(join(SPOOL_DIR, name));
48
+ } catch {
49
+ /* 已不存在 */
50
+ }
51
+ }
52
+
53
+ /** 当前 spool 积压数(用于运行指标)。 */
54
+ export function countSpool(): number {
55
+ try {
56
+ return readdirSync(SPOOL_DIR).filter((f) => f.endsWith(".json")).length;
57
+ } catch {
58
+ return 0;
59
+ }
60
+ }
@@ -0,0 +1,116 @@
1
+ // 事件与 API 契约类型。Hook / Daemon / 查看页共享,固化接口。
2
+
3
+ export type HookEventType =
4
+ | "SessionStart"
5
+ | "UserPromptSubmit"
6
+ | "PreToolUse"
7
+ | "PostToolUse"
8
+ | "Stop"
9
+ | "SubagentStop"
10
+ | "PreCompact"
11
+ | "SessionEnd";
12
+
13
+ /** Hook 采集后、落盘与转发的事件信封。payload 为 Claude 注入 stdin 的原始 JSON(透传)。 */
14
+ export interface HookEvent {
15
+ eventId: string; // 稳定 id,幂等去重用
16
+ type: HookEventType;
17
+ timestamp: number; // ms
18
+ cwd: string; // process.cwd()
19
+ sessionId: string;
20
+ pid: number;
21
+ payload: unknown;
22
+ }
23
+
24
+ /** pid 文件内容。 */
25
+ export interface PidFile {
26
+ pid: number;
27
+ port: number;
28
+ token: string;
29
+ startedAt: number; // ms
30
+ }
31
+
32
+ /** GET /api/health 响应。service 字段用于 Hook「认自己人」。 */
33
+ export interface HealthResponse {
34
+ service: string;
35
+ version: string;
36
+ pid: number;
37
+ uptime: number; // ms
38
+ }
39
+
40
+ /** GET /api/stats 响应。 */
41
+ export interface StatsResponse {
42
+ service: string;
43
+ version: string;
44
+ pid: number;
45
+ uptime: number;
46
+ spoolBacklog: number;
47
+ eventsPerSec: number;
48
+ totalEvents: number;
49
+ lastError: { time: number; message: string } | null;
50
+ logTail: string[];
51
+ }
52
+
53
+ /** GET /api/events 响应。 */
54
+ export interface EventsResponse {
55
+ events: HookEvent[];
56
+ }
57
+
58
+ /** 单次 assistant 响应的 token 用量(来自 transcript message.usage 的扁平四字段,缺失按 0)。 */
59
+ export interface TokenUsage {
60
+ input: number;
61
+ output: number;
62
+ cacheCreation: number;
63
+ cacheRead: number;
64
+ }
65
+
66
+ /** 单个 session 的概览(查看页 session 树用)。tokenTotal 来自 transcript 汇总,读不到为 null。 */
67
+ export interface SessionSummary {
68
+ sessionId: string;
69
+ cwd: string;
70
+ lastActive: number;
71
+ eventCount: number;
72
+ lastType: HookEventType | null;
73
+ tokenTotal?: TokenUsage | null;
74
+ }
75
+
76
+ /** GET /api/sessions 响应。 */
77
+ export interface SessionsResponse {
78
+ sessions: SessionSummary[];
79
+ }
80
+
81
+ /** GET /api/transcript 响应里的单条消息(daemon 解析 jsonl 产物,对话视图消费)。 */
82
+ export interface TranscriptMessage {
83
+ role: "user" | "assistant" | "tool";
84
+ text: string;
85
+ thinking?: string;
86
+ tools: { name: string; input: unknown }[];
87
+ toolName?: string;
88
+ isError?: boolean;
89
+ ts?: number;
90
+ usage?: TokenUsage; // 仅 assistant 有,来自 message.usage
91
+ }
92
+
93
+ /** git commit 的单个文件变更(来自 git log --numstat;二进制文件 added/deleted 为 0)。 */
94
+ export interface CommitFile {
95
+ path: string;
96
+ added: number;
97
+ deleted: number;
98
+ }
99
+
100
+ /** 单条 git commit(/api/commits 返回)。added/deleted 为其下 files 的合计。 */
101
+ export interface CommitLog {
102
+ hash: string;
103
+ time: number; // ms,提交时间(%cI)
104
+ author: string;
105
+ subject: string;
106
+ files: CommitFile[];
107
+ added: number;
108
+ deleted: number;
109
+ }
110
+
111
+ /** GET /api/commits 响应。非 git 目录或 git 不可用时 commits 为空、带 error。 */
112
+ export interface CommitsResponse {
113
+ cwd: string;
114
+ commits: CommitLog[];
115
+ error?: string;
116
+ }