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,147 @@
1
+ // SQLite 存储:单库 + cwd 字段(按 cwd 过滤实现隔离,便于查看页跨项目聚合)。
2
+ // 幂等:PRIMARY KEY (session_id, event_id) + INSERT OR IGNORE。
3
+ import { Database } from "bun:sqlite";
4
+ import { DB_FILE } from "../shared/paths";
5
+ import { deriveStableEventId } from "../shared/id";
6
+ import type { HookEvent, HookEventType, SessionSummary } from "../shared/types";
7
+
8
+ const SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS events (
10
+ event_id TEXT NOT NULL,
11
+ session_id TEXT NOT NULL,
12
+ type TEXT NOT NULL,
13
+ timestamp INTEGER NOT NULL,
14
+ cwd TEXT NOT NULL,
15
+ pid INTEGER NOT NULL,
16
+ payload TEXT NOT NULL,
17
+ ingested_at INTEGER NOT NULL,
18
+ PRIMARY KEY (session_id, event_id)
19
+ );
20
+ CREATE INDEX IF NOT EXISTS idx_events_time ON events(timestamp DESC);
21
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, timestamp DESC);
22
+ CREATE INDEX IF NOT EXISTS idx_events_cwd ON events(cwd, timestamp DESC);
23
+ `;
24
+
25
+ const INSERT = `
26
+ INSERT OR IGNORE INTO events (event_id, session_id, type, timestamp, cwd, pid, payload, ingested_at)
27
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
28
+ `;
29
+
30
+ export interface QueryOpts {
31
+ cwd?: string;
32
+ sessionId?: string;
33
+ type?: string;
34
+ since?: number;
35
+ limit?: number;
36
+ }
37
+
38
+ export class Store {
39
+ private db: Database;
40
+
41
+ constructor() {
42
+ this.db = new Database(DB_FILE, { create: true });
43
+ this.db.exec("PRAGMA journal_mode = WAL;");
44
+ this.db.exec("PRAGMA synchronous = NORMAL;");
45
+ this.db.exec(SCHEMA);
46
+ }
47
+
48
+ /** 幂等插入。true=新插入,false=已存在(被去重)。
49
+ * eventId 在此由内容派生(sessionId+type+payload),忽略客户端/hook 传入的随机 id——
50
+ * 这样即使同一事件被多个 hook 进程采集,派生主键相同,INSERT OR IGNORE 仍能去重。
51
+ * 入库点统一在此,HTTP 热路径与 spool 回捞共享,互为兜底。 */
52
+ insert(ev: HookEvent): boolean {
53
+ const id = deriveStableEventId(ev);
54
+ const r = this.db
55
+ .prepare(INSERT)
56
+ .run(
57
+ id,
58
+ ev.sessionId,
59
+ ev.type,
60
+ ev.timestamp,
61
+ ev.cwd,
62
+ ev.pid,
63
+ JSON.stringify(ev.payload ?? null),
64
+ Date.now(),
65
+ );
66
+ return r.changes > 0;
67
+ }
68
+
69
+ query(opts: QueryOpts): HookEvent[] {
70
+ const where: string[] = [];
71
+ const params: Array<string | number> = [];
72
+ if (opts.cwd) { where.push("cwd = ?"); params.push(opts.cwd); }
73
+ if (opts.sessionId) { where.push("session_id = ?"); params.push(opts.sessionId); }
74
+ if (opts.type) { where.push("type = ?"); params.push(opts.type); }
75
+ if (typeof opts.since === "number") { where.push("timestamp >= ?"); params.push(opts.since); }
76
+ const clause = where.length ? `WHERE ${where.join(" AND ")}` : "";
77
+ const limit = Math.min(Math.max(opts.limit ?? 200, 1), 2000);
78
+ const rows = this.db
79
+ .prepare(
80
+ `SELECT event_id, session_id, type, timestamp, cwd, pid, payload FROM events ${clause} ORDER BY timestamp DESC LIMIT ?`,
81
+ )
82
+ .all(...params, limit);
83
+ return rows.map(rowToEvent);
84
+ }
85
+
86
+ sessions(): SessionSummary[] {
87
+ const rows = this.db
88
+ .prepare(
89
+ `SELECT session_id, cwd, MAX(timestamp) AS last_active, COUNT(*) AS event_count,
90
+ (SELECT type FROM events e2 WHERE e2.session_id = e.session_id ORDER BY timestamp DESC LIMIT 1) AS last_type
91
+ FROM events e
92
+ GROUP BY session_id, cwd
93
+ ORDER BY last_active DESC
94
+ LIMIT 500`,
95
+ )
96
+ .all() as Array<{
97
+ session_id: string;
98
+ cwd: string;
99
+ last_active: number;
100
+ event_count: number;
101
+ last_type: HookEventType | null;
102
+ }>;
103
+ return rows.map((r) => ({
104
+ sessionId: r.session_id,
105
+ cwd: r.cwd,
106
+ lastActive: r.last_active,
107
+ eventCount: r.event_count,
108
+ lastType: r.last_type,
109
+ }));
110
+ }
111
+
112
+ count(): number {
113
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM events").get() as { n: number };
114
+ return row.n;
115
+ }
116
+
117
+ close(): void {
118
+ this.db.close();
119
+ }
120
+ }
121
+
122
+ function rowToEvent(row: unknown): HookEvent {
123
+ const r = row as {
124
+ event_id: string;
125
+ session_id: string;
126
+ type: HookEventType;
127
+ timestamp: number;
128
+ cwd: string;
129
+ pid: number;
130
+ payload: string;
131
+ };
132
+ let payload: unknown = null;
133
+ try {
134
+ payload = JSON.parse(r.payload);
135
+ } catch {
136
+ /* payload 损坏则置 null */
137
+ }
138
+ return {
139
+ eventId: r.event_id,
140
+ sessionId: r.session_id,
141
+ type: r.type,
142
+ timestamp: r.timestamp,
143
+ cwd: r.cwd,
144
+ pid: r.pid,
145
+ payload,
146
+ };
147
+ }
@@ -0,0 +1,35 @@
1
+ // 会话级 token 总量的 mtime 缓存。
2
+ // /api/sessions 每 2s 被查看页轮询,逐 session 读 transcript 汇总 usage 会很重。
3
+ // 这里按 transcriptPath 缓存「mtime → tokenTotal」:文件没变就直接返回,变了才重读重算。
4
+ // 冷启动逐步填充,稳态全命中。任何异常返回 null(不影响 sessions 列表渲染)。
5
+ import { statSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { parseTranscript, sumUsage } from "./transcript";
8
+ import type { TokenUsage } from "../shared/types";
9
+
10
+ interface Entry {
11
+ mtimeMs: number;
12
+ total: TokenUsage;
13
+ }
14
+
15
+ const cache = new Map<string, Entry>();
16
+
17
+ /** 返回某 transcript 的会话级 token 总量(带 mtime 缓存);读不到/解析失败返回 null。 */
18
+ export function getSessionTokenTotal(transcriptPath: string): TokenUsage | null {
19
+ const realPath = transcriptPath.replace(/^~/, homedir());
20
+ let mtimeMs: number;
21
+ try {
22
+ mtimeMs = statSync(realPath).mtimeMs;
23
+ } catch {
24
+ return null;
25
+ }
26
+ const hit = cache.get(transcriptPath);
27
+ if (hit && hit.mtimeMs === mtimeMs) return hit.total;
28
+ try {
29
+ const total = sumUsage(parseTranscript(transcriptPath));
30
+ cache.set(transcriptPath, { mtimeMs, total });
31
+ return total;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
@@ -0,0 +1,130 @@
1
+ // 解析 Claude Code transcript jsonl(~/.claude/projects/<project>/<session>.jsonl)为对话消息。
2
+ // 用于「对话视图」:完整还原用户提问 + Claude 回复 + 工具调用。
3
+ // (事件流里若 Stop 未采集,这里仍能拿到完整记录,因为 transcript 由 Claude Code 自己持续写入。)
4
+ // assistant 消息额外提取 message.usage(token 用量),供对话明细与会话级汇总。
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import type { TokenUsage, TranscriptMessage } from "../shared/types";
8
+
9
+ // TranscriptMessage 已移至 shared/types(前端 React 也复用同一契约);此处 re-export 保持向后兼容。
10
+ export type { TranscriptMessage };
11
+
12
+ /** 读 transcript jsonl,解析成对话消息(跳过 thinking、tool_result 等非对话内容)。 */
13
+ export function parseTranscript(transcriptPath: string): TranscriptMessage[] {
14
+ const path = transcriptPath.replace(/^~/, homedir());
15
+ if (!existsSync(path)) throw new Error(`transcript not found: ${path}`);
16
+ const lines = readFileSync(path, "utf8").split("\n").filter(Boolean);
17
+ const messages: TranscriptMessage[] = [];
18
+ const toolUseNames = new Map<string, string>(); // tool_use_id -> name,供 tool_result 关联工具名
19
+ for (const line of lines) {
20
+ let obj: Record<string, unknown>;
21
+ try {
22
+ obj = JSON.parse(line);
23
+ } catch {
24
+ continue;
25
+ }
26
+ const message = obj.message as Record<string, unknown> | undefined;
27
+ if (!message) continue;
28
+ const role = message.role;
29
+ const content = message.content;
30
+ const ts = typeof obj.timestamp === "string"
31
+ ? Date.parse(obj.timestamp)
32
+ : (obj.timestamp as number | undefined);
33
+
34
+ if (role === "user") {
35
+ // string = 用户提问;array 多含 tool_result(作为独立「工具结果」消息)+ 可能的 text 段
36
+ if (typeof content === "string") {
37
+ if (content.trim()) messages.push({ role: "user", text: content, tools: [], ts });
38
+ } else if (Array.isArray(content)) {
39
+ const text = content
40
+ .filter((c) => (c as Record<string, unknown>).type === "text")
41
+ .map((c) => (c as Record<string, unknown>).text as string)
42
+ .join("\n");
43
+ if (text) messages.push({ role: "user", text, tools: [], ts });
44
+ for (const c of content) {
45
+ const ce = c as Record<string, unknown>;
46
+ if (ce.type !== "tool_result") continue;
47
+ const rc = ce.content;
48
+ let rText: string;
49
+ if (typeof rc === "string") rText = rc;
50
+ else if (Array.isArray(rc)) {
51
+ rText = rc
52
+ .filter((x) => (x as Record<string, unknown>).type === "text")
53
+ .map((x) => (x as Record<string, unknown>).text as string)
54
+ .join("\n");
55
+ } else rText = "";
56
+ const id = typeof ce.tool_use_id === "string" ? ce.tool_use_id : "";
57
+ messages.push({
58
+ role: "tool",
59
+ text: rText,
60
+ tools: [],
61
+ toolName: id ? toolUseNames.get(id) : undefined,
62
+ isError: ce.is_error === true,
63
+ ts,
64
+ });
65
+ }
66
+ }
67
+ } else if (role === "assistant") {
68
+ const usage = readUsage(message.usage);
69
+ if (Array.isArray(content)) {
70
+ const text = content
71
+ .filter((c) => (c as Record<string, unknown>).type === "text")
72
+ .map((c) => (c as Record<string, unknown>).text as string)
73
+ .join("\n");
74
+ const thinking = content
75
+ .filter((c) => (c as Record<string, unknown>).type === "thinking")
76
+ .map((c) => (c as Record<string, unknown>).thinking as string)
77
+ .join("\n\n");
78
+ const tools = content
79
+ .filter((c) => (c as Record<string, unknown>).type === "tool_use")
80
+ .map((c) => {
81
+ const ce = c as Record<string, unknown>;
82
+ const id = typeof ce.id === "string" ? ce.id : "";
83
+ const name = ce.name as string;
84
+ if (id && name) toolUseNames.set(id, name);
85
+ return { name, input: ce.input };
86
+ });
87
+ if (text || thinking || tools.length)
88
+ messages.push({ role: "assistant", text, thinking, tools, ts, usage });
89
+ }
90
+ }
91
+ }
92
+ return messages;
93
+ }
94
+
95
+ /** 从 message.usage(Anthropic 扁平四字段)提取 token 用量;无任何数值字段则 undefined。 */
96
+ function readUsage(raw: unknown): TokenUsage | undefined {
97
+ if (!raw || typeof raw !== "object") return undefined;
98
+ const u = raw as Record<string, unknown>;
99
+ const num = (k: string): number => {
100
+ const v = u[k];
101
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
102
+ };
103
+ const has = [
104
+ "input_tokens",
105
+ "output_tokens",
106
+ "cache_creation_input_tokens",
107
+ "cache_read_input_tokens",
108
+ ].some((k) => typeof u[k] === "number");
109
+ if (!has) return undefined;
110
+ return {
111
+ input: num("input_tokens"),
112
+ output: num("output_tokens"),
113
+ cacheCreation: num("cache_creation_input_tokens"),
114
+ cacheRead: num("cache_read_input_tokens"),
115
+ };
116
+ }
117
+
118
+ /** 累加所有 assistant 消息的 usage(会话级 token 总量);无 usage 则全 0。 */
119
+ export function sumUsage(messages: TranscriptMessage[]): TokenUsage {
120
+ const total: TokenUsage = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
121
+ for (const m of messages) {
122
+ if (m.usage) {
123
+ total.input += m.usage.input;
124
+ total.output += m.usage.output;
125
+ total.cacheCreation += m.usage.cacheCreation;
126
+ total.cacheRead += m.usage.cacheRead;
127
+ }
128
+ }
129
+ return total;
130
+ }