@aiti-ai/aiti-git-trace 0.0.0-stage → 0.2.12

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 (41) hide show
  1. package/AGENTS.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +38 -2
  4. package/api.ts +424 -0
  5. package/check-client.mjs +273 -0
  6. package/client/src/common.ts +179 -0
  7. package/client/src/entry.tsx +81 -0
  8. package/client/src/host-shim.ts +118 -0
  9. package/client/src/jsx-runtime.ts +2 -0
  10. package/client/src/panels.tsx +1377 -0
  11. package/client/src/trace-app.tsx +275 -0
  12. package/client/src/trace-files-row.pure.ts +88 -0
  13. package/client/src/trace-files-row.tsx +110 -0
  14. package/client/src/version-mention.ts +90 -0
  15. package/deploy-commit.mjs +52 -0
  16. package/dev-pack.mjs +246 -0
  17. package/dist/aiti-git-trace-client.mjs +4 -0
  18. package/dist/entry-C_le59dp.js +1594 -0
  19. package/dist/panels-BpC4XLni.js +1522 -0
  20. package/ext-icons-deploy.mjs +24 -0
  21. package/icon.svg +1 -0
  22. package/icons/git-branch.svg +1 -0
  23. package/icons/git-commit-horizontal.svg +1 -0
  24. package/index.ts +705 -0
  25. package/package.json +33 -4
  26. package/reconcile-deps.mjs +41 -0
  27. package/reload-instances.mjs +60 -0
  28. package/scripts/aiti-trace.mjs +117 -0
  29. package/skills/index.json +1 -0
  30. package/skills/rollback-session/SKILL.md +34 -0
  31. package/skills/trace-ops/SKILL.md +31 -0
  32. package/skills/watch-path/SKILL.md +34 -0
  33. package/test/fixtures/ops-sample.jsonl +3 -0
  34. package/tools/deploy.mjs +103 -0
  35. package/tools/trace-compact.mjs +58 -0
  36. package/trace-core.test.mjs +1120 -0
  37. package/trace-core.ts +1346 -0
  38. package/trace-files-row.test.mjs +66 -0
  39. package/tsconfig.check.json +21 -0
  40. package/version-mention.test.mjs +76 -0
  41. package/vite.config.ts +43 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * check-client —— 扩展 client .mjs/.js 的 TS 残留 tripwire(零依赖,纯 node)。
3
+ *
4
+ * 背景:aiti-plan-review 事故(docs/aiti-ext-client-hardening-plan.md §1)——
5
+ * `useState<any>(null)` 解析为合法 JS 比较表达式 `useState < any > (null)`,
6
+ * node --check / TS 解析器 / babel 全不报错,只有运行时才炸。纯语法检查
7
+ * 原理性无效,只能启发式规则 + 渲染期错误边界兜底。
8
+ *
9
+ * 用法:
10
+ * CLI node check-client.mjs <file|dir>... FAIL → exit 1
11
+ * 自测 node check-client.mjs --selftest fixtures 全过 → exit 0
12
+ * API lintClientSource(src) → violations[] { line, level, rule, text }
13
+ * lintPath(path) → { files, violations }(文件或目录,递归)
14
+ * checkClientDir(dir,{log}) FAIL → throw(deploy 脚本用)
15
+ *
16
+ * 词法:规则匹配前先剥离注释/字符串/模板字面量(${} 插值内代码保留检查)——
17
+ * aiti-editor 的 Monaco TS stub 模板串、JSDoc @return 都是合法内容,不剥全误报。
18
+ * 已知极限:regex 字面量含引号等 lexer 歧义允许漏报(tripwire 定位非编译器)。
19
+ *
20
+ * 豁免:目标行或其上一行含 `check-client:allow` 即跳过该行。
21
+ * 权威源:本文件;aiti-extman 部署副本由其 tools/deploy.mjs 拷贝(改动两处同步)。
22
+ */
23
+ import { readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { join, resolve } from "node:path";
25
+
26
+ // ── 规则(分级)──────────────────────────────────────────────────────────────
27
+
28
+ const RULES = [
29
+ // FAIL:合法 JS 但运行时必炸 / 纯 TS 语法
30
+ { level: "FAIL", re: /\b(useState|useRef|useMemo|useCallback|useReducer|useImperativeHandle)\s*</, why: "React hook 泛型(运行时必炸)" },
31
+ // 注:含「any/unknown/string」字样的规则用字符串构 RegExp——本文件自身也
32
+ // 会被扫,正则字面量里的 (?:any 会被自己命中(词法器不处理 regex 字面量),
33
+ // 字符串字面量则会被词法遮蔽。
34
+ { level: "FAIL", re: new RegExp("\\bas\\s+(?:" + "any|unknown|const)\\b"), why: "as 断言" },
35
+ { level: "FAIL", re: new RegExp(":\\s*(?:" + "any|unknown)\\b"), why: ": any/: unknown 注解" },
36
+ { level: "FAIL", re: /\b(?:import|export)\s+type\s/, why: "import/export type" },
37
+ { level: "FAIL", re: /\b(?:interface|enum)\s+[A-Za-z_$]/, why: "interface/enum 声明" },
38
+ // warn:疑似残留,人工确认,不阻断
39
+ { level: "warn", re: new RegExp(":\\s*(?:" + "string|number|boolean|void)\\b(?!\\s*[._\\w])"), why: "疑似类型注解" },
40
+ { level: "warn", re: /\bsatisfies\s/, why: "satisfies" },
41
+ { level: "warn", re: /\b(?:Record|Partial|Readonly|Omit|Pick)\s*</, why: "TS 工具类型" },
42
+ ];
43
+
44
+ // ── 词法剥离:注释/字符串/模板字面量 → 空格(保留换行,行号不漂移)─────────
45
+ export function stripLiterals(src) {
46
+ const out = src.split("");
47
+ const n = src.length;
48
+ const mask = (from, to) => {
49
+ for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " ";
50
+ };
51
+ let mode = "code";
52
+ let depth = 0;
53
+ const stack = []; // 模板 ${ 插值进入时的 brace 深度(支持嵌套模板)
54
+ let i = 0;
55
+ while (i < n) {
56
+ const c = src[i];
57
+ const d = i + 1 < n ? src[i + 1] : "";
58
+ if (mode === "code") {
59
+ if (c === "/" && d === "/") {
60
+ const j = src.indexOf("\n", i);
61
+ const end = j === -1 ? n : j;
62
+ mask(i, end);
63
+ i = end;
64
+ } else if (c === "/" && d === "*") {
65
+ const j = src.indexOf("*/", i + 2);
66
+ const end = j === -1 ? n : j + 2;
67
+ mask(i, end);
68
+ i = end;
69
+ } else if (c === "'" || c === '"') {
70
+ let j = i + 1;
71
+ while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
72
+ if (j < n && src[j] === c) {
73
+ mask(i, j + 1);
74
+ i = j + 1;
75
+ } else {
76
+ // 未闭合到行尾 → 可能是 regex 字面量歧义,只遮开引号,按代码继续
77
+ out[i] = " ";
78
+ i++;
79
+ }
80
+ } else if (c === "`") {
81
+ mode = "tpl";
82
+ out[i] = " ";
83
+ i++;
84
+ } else if (c === "{") {
85
+ depth++;
86
+ i++;
87
+ } else if (c === "}") {
88
+ if (stack.length && depth === stack[stack.length - 1]) {
89
+ stack.pop();
90
+ mode = "tpl"; // 插值结束回模板
91
+ out[i] = " ";
92
+ } else {
93
+ depth = Math.max(0, depth - 1);
94
+ }
95
+ i++;
96
+ } else i++;
97
+ } else if (mode === "tpl") {
98
+ if (c === "\\") {
99
+ mask(i, Math.min(i + 2, n));
100
+ i += 2;
101
+ } else if (c === "`") {
102
+ mode = "code";
103
+ out[i] = " ";
104
+ i++;
105
+ } else if (c === "$" && d === "{") {
106
+ stack.push(depth);
107
+ mode = "code";
108
+ mask(i, i + 2);
109
+ i += 2;
110
+ } else {
111
+ if (c !== "\n") out[i] = " ";
112
+ i++;
113
+ }
114
+ } else {
115
+ // 不可达:line/block/字符串均在 code 分支内联处理
116
+ i++;
117
+ }
118
+ }
119
+ return out.join("");
120
+ }
121
+
122
+ // ── 单源检查 ────────────────────────────────────────────────────────────────
123
+ export function lintClientSource(src) {
124
+ const masked = stripLiterals(src);
125
+ const origLines = src.split("\n");
126
+ const maskedLines = masked.split("\n");
127
+ const violations = [];
128
+ for (let i = 0; i < maskedLines.length; i++) {
129
+ const prev = i > 0 ? origLines[i - 1] : "";
130
+ if (origLines[i].includes("check-client:allow") || prev.includes("check-client:allow")) continue;
131
+ for (const { level, re, why } of RULES) {
132
+ if (re.test(maskedLines[i])) {
133
+ violations.push({ line: i + 1, level, rule: why, text: origLines[i].trim().slice(0, 90) });
134
+ }
135
+ }
136
+ }
137
+ return violations;
138
+ }
139
+
140
+ // ── 文件/目录检查 ───────────────────────────────────────────────────────────
141
+ const SKIP_DIR = new Set(["node_modules", ".git"]);
142
+ /** 超过此体积视为 vendored bundle(如 Monaco ts.worker,合法含 interface 字样),不查。 */
143
+ const MAX_BYTES = 512 * 1024;
144
+ const isClientFile = (name) => /\.(mjs|js|cjs)$/.test(name) && !/\.min\./.test(name) && !/\.test\./.test(name);
145
+
146
+ export function lintPath(p, out = { files: 0, violations: [] }) {
147
+ const st = statSync(p);
148
+ if (st.isDirectory()) {
149
+ for (const name of readdirSync(p)) {
150
+ if (SKIP_DIR.has(name)) continue;
151
+ lintPath(join(p, name), out);
152
+ }
153
+ return out;
154
+ }
155
+ const base = p.split("/").pop();
156
+ if (!isClientFile(base)) return out;
157
+ if (st.size > MAX_BYTES) return out; // vendored bundle 豁免
158
+ out.files++;
159
+ for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
160
+ return out;
161
+ }
162
+
163
+ export function formatViolations(violations) {
164
+ return violations
165
+ .map((v) => ` ${v.level} ${v.file}:${v.line} [${v.rule}] ${v.text}`)
166
+ .join("\n");
167
+ }
168
+
169
+ /** deploy 脚本用:检查目录,FAIL 抛错(消息含违规清单),warn 只打日志不阻断。 */
170
+ export function checkClientDir(dir, { log = console.log } = {}) {
171
+ const { files, violations } = lintPath(resolve(dir));
172
+ const fails = violations.filter((v) => v.level === "FAIL");
173
+ const warns = violations.filter((v) => v.level === "warn");
174
+ if (warns.length) log(`[check-client] ${warns.length} 处疑似 TS 残留(不阻断,建议人工确认):\n${formatViolations(warns)}`);
175
+ if (fails.length) {
176
+ throw new Error(
177
+ `client 检查未通过:${files} 个文件中 ${fails.length} 处 TS 残留(.mjs 里是合法 JS,语法检查不报,运行时必炸)。\n` +
178
+ formatViolations(fails) +
179
+ `\n修复或在该行加注释 // check-client:allow 豁免。详见 docs/aiti-ext-client-hardening-plan.md`,
180
+ );
181
+ }
182
+ if (files) log(`[check-client] ${files} 个 client 文件检查通过`);
183
+ return { files, fails: fails.length, warns: warns.length };
184
+ }
185
+
186
+ /**
187
+ * extman 安装用:扫整个扩展目录(含第三方,不遵循 client/ 约定)。
188
+ * 在 SKIP_DIR 之上额外跳过构建产物/vendored 目录(Monaco ts.worker、语言包等
189
+ * 合法包含 TS 字样,词法器对 minified 代码不提供保证);体积上限同上。
190
+ */
191
+ export function lintExtensionDir(dir) {
192
+ const out = { files: 0, violations: [] };
193
+ const walk = (p) => {
194
+ const st = statSync(p);
195
+ if (st.isDirectory()) {
196
+ for (const name of readdirSync(p)) {
197
+ if (SKIP_DIR.has(name) || VENDORED_DIRS.has(name)) continue;
198
+ walk(join(p, name));
199
+ }
200
+ return;
201
+ }
202
+ const base = p.split("/").pop();
203
+ if (!isClientFile(base) || st.size > MAX_BYTES) return;
204
+ out.files++;
205
+ for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
206
+ };
207
+ walk(resolve(dir));
208
+ return out;
209
+ }
210
+ const VENDORED_DIRS = new Set(["vendor", "monaco", "dist", "build", "assets", "static", "bundles", "chunks"]);
211
+
212
+ // ── CLI ─────────────────────────────────────────────────────────────────────
213
+ function selftest() {
214
+ const MUST_FAIL = [
215
+ "const [st, setSt] = useState<any>(null);",
216
+ "const r = useRef<number>(null);",
217
+ "const x = v as any;",
218
+ "function f(a: unknown) {}",
219
+ 'import type { Foo } from "./x";',
220
+ "interface Foo { a: string }",
221
+ "enum Color { Red }",
222
+ "const y = v as const;",
223
+ ];
224
+ const MUST_WARN = [
225
+ "const opts = { mode: string };",
226
+ "x satisfies Y;",
227
+ "const m: Record<string, any> = {};",
228
+ ];
229
+ const MUST_PASS = [
230
+ 'const REACT_TYPE_STUB = `\ndeclare module "react" {\n export function useState<T>(init: T): [T];\n export const Fragment: any;\n}\n`;',
231
+ "/** @return {{chars1: string, chars2: string}} */",
232
+ "const [s] = useState(null);",
233
+ "useState<any>(null); // check-client:allow",
234
+ "// check-client:allow\nuseState<never>(0);",
235
+ "const cmp = a < b && c > (d);",
236
+ "if (a // 注释里 as any 不算\n ) run();",
237
+ ];
238
+ let bad = 0;
239
+ const check = (src, expect, desc) => {
240
+ const v = lintClientSource(src);
241
+ const fails = v.filter((x) => x.level === "FAIL");
242
+ const ok = expect === "FAIL" ? fails.length > 0 : expect === "PASS" ? v.length === 0 : v.length > 0 && fails.length === 0;
243
+ if (!ok) {
244
+ bad++;
245
+ console.log(`SELFTEST FAIL [${desc}] 期望 ${expect},实际 ${JSON.stringify(v)}`);
246
+ }
247
+ };
248
+ MUST_FAIL.forEach((s) => check(s, "FAIL", s.slice(0, 40)));
249
+ MUST_WARN.forEach((s) => check(s, "WARN", s.slice(0, 40)));
250
+ MUST_PASS.forEach((s) => check(s, "PASS", s.replace(/\n/g, "⏎").slice(0, 40)));
251
+ if (bad) {
252
+ console.log(`selftest: ${bad} 处未过`);
253
+ process.exit(1);
254
+ }
255
+ console.log("selftest: 全部通过(8 FAIL / 3 WARN / 7 PASS)");
256
+ }
257
+
258
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
259
+ if (process.argv.includes("--selftest")) {
260
+ selftest();
261
+ } else if (process.argv.length > 2) {
262
+ const all = { files: 0, violations: [] };
263
+ for (const p of process.argv.slice(2)) lintPath(resolve(p), all);
264
+ const fails = all.violations.filter((v) => v.level === "FAIL");
265
+ const warns = all.violations.filter((v) => v.level === "warn");
266
+ if (all.violations.length) console.log(formatViolations(all.violations));
267
+ console.log(`\n${all.files} 个文件:FAIL=${fails.length} warn=${warns.length}`);
268
+ process.exit(fails.length ? 1 : 0);
269
+ } else {
270
+ console.log("用法:node check-client.mjs <file|dir>... | --selftest");
271
+ process.exit(2);
272
+ }
273
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * aiti-git-trace · 共享层(薄壳与重面板共用)
3
+ *
4
+ * 拆包契约(2026-09-13 薄壳拆分):本文件只放两侧都要用的东西——session 状态、
5
+ * API 小工具、open 事件/浮动键、样式。**不得 import 重面板模块**(panels.tsx 反向依赖本文件)。
6
+ */
7
+ import { useState, useEffect, apiUrl } from "./host-shim";
8
+ import { usePresent, openFloat, closePanel } from "../../../_shared/ext-float-dock";
9
+
10
+ export const TIMELINE_KEY = "aiti-git-trace:timeline";
11
+ export const WATCH_KEY = "aiti-git-trace:watch";
12
+
13
+ // ---------- 小工具 ----------
14
+
15
+ export const OPEN_EVENT = "aiti-git-trace-open";
16
+ export function openPanel(kind: string, data?: any) {
17
+ (window as any).dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: { kind, data } }));
18
+ }
19
+
20
+ /** dock v3:kind → itemKey 映射 + 状态机收口(CustomEvent 只作入口总线,开/关走 openFloat/closePanel)。
21
+ * 开/关走 openFloat/closePanel;open 态 = 非 closed(浮窗与停靠都算开,effect 数据加载照旧)。
22
+ * 返回 [open, setOpen, presented]——presented 供渲染分面(floating / docked:<zone>)。 */
23
+ export function useOpenKind(kind: string, onData?: (d: any) => void): [boolean, (v: boolean) => void, string] {
24
+ const key = kind === "timeline" ? TIMELINE_KEY : kind === "watch" ? WATCH_KEY : "aiti-git-trace:" + kind;
25
+ const st = usePresent(key);
26
+ const open = st !== "closed";
27
+ useEffect(() => {
28
+ const fn = (e: Event) => {
29
+ const d = (e as CustomEvent).detail || {};
30
+ if (d.kind === kind) { onData && onData(d.data); openFloat(key); }
31
+ else if (d.kind === null) { closePanel(TIMELINE_KEY); closePanel(WATCH_KEY); }
32
+ };
33
+ (window as any).addEventListener(OPEN_EVENT, fn);
34
+ return () => (window as any).removeEventListener(OPEN_EVENT, fn);
35
+ }, []);
36
+ const setOpen = (v: boolean) => { if (v) openFloat(key); else closePanel(key); };
37
+ return [open, setOpen, st];
38
+ }
39
+
40
+ export const EXT_API = "/api/extensions/aiti-git-trace";
41
+
42
+ export async function apiGet(path: string): Promise<any> {
43
+ const r = await fetch(apiUrl(EXT_API + path), { headers: { accept: "application/json" } });
44
+ return r.json();
45
+ }
46
+ export async function apiPost(path: string, body: any, method = "POST"): Promise<any> {
47
+ const r = await fetch(apiUrl(EXT_API + path), {
48
+ method, headers: { "content-type": "application/json" },
49
+ body: body === undefined ? undefined : JSON.stringify(body),
50
+ });
51
+ return r.json();
52
+ }
53
+
54
+ // ---------- /ops 共享取数(§11.3 性能审计:badge/footer 此前各持独立缓存,每开页重复拉一次) ----------
55
+ const OPS_TTL_MS = 4000;
56
+
57
+ /** 台账 ops 共享取数(badge/footer 同一缓存):TTL 内直用;过期带 If-None-Match 再校验
58
+ * (服务端台账未变 → 304 零传输续用);失败保留旧 rows 防闪空。缓存挂 SHARED 单例
59
+ * (本模块双实例约束,见 SHARED 注释)。 */
60
+ export function fetchOpsCached(sid: string): Promise<any[]> {
61
+ if (!sid) return Promise.resolve([]);
62
+ const now = Date.now();
63
+ const c = SHARED.opsCache;
64
+ if (c && c.sid === sid && now - c.at < OPS_TTL_MS) return Promise.resolve(c.rows);
65
+ if (SHARED.opsInflight && SHARED.opsInflight.sid === sid) return SHARED.opsInflight.promise;
66
+ const headers: Record<string, string> = { accept: "application/json" };
67
+ if (c && c.sid === sid && c.etag) headers["if-none-match"] = c.etag;
68
+ const promise = fetch(apiUrl(EXT_API + "/ops?session=" + encodeURIComponent(sid)), { headers })
69
+ .then(async (r) => {
70
+ if (r.status === 304 && SHARED.opsCache && SHARED.opsCache.sid === sid) {
71
+ SHARED.opsCache.at = Date.now();
72
+ return SHARED.opsCache.rows;
73
+ }
74
+ const body = await r.json().catch(() => null);
75
+ const rows = (body && body.ops) || [];
76
+ SHARED.opsCache = { sid, at: Date.now(), etag: r.headers.get("etag"), rows };
77
+ return rows;
78
+ })
79
+ .catch(() => (SHARED.opsCache && SHARED.opsCache.sid === sid ? SHARED.opsCache.rows : []));
80
+ SHARED.opsInflight = { sid, promise };
81
+ const clearInflight = () => { if (SHARED.opsInflight && SHARED.opsInflight.sid === sid) SHARED.opsInflight = null; };
82
+ promise.then(clearInflight, clearInflight);
83
+ return promise;
84
+ }
85
+
86
+ /** SSE op 事件失效:清指定会话缓存条目(下一次取全量 200)。sid 空 = 全清。 */
87
+ export function invalidateOpsCache(sid?: string): void {
88
+ if (SHARED.opsCache && (!sid || SHARED.opsCache.sid === sid)) SHARED.opsCache = null;
89
+ }
90
+
91
+ /** 宿主每次渲染都会下发 currentSession——组件挂载时捕获到模块级,供 getSessionId() 使用 */
92
+ /**
93
+ * 宿主每次渲染都会下发 currentSession——组件挂载时捕获,供 getSessionId() 使用。
94
+ *
95
+ * ⚠ 薄壳拆分后本模块存在两份实例(批量包内联的 shell 副本 + panels chunk 经相对
96
+ * import 拉起的 entry 文件副本)——可变状态必须挂 globalThis 单例共享,否则
97
+ * 面板侧读不到会话(会打出无 session 的幻影请求)。
98
+ */
99
+ const SHARED: {
100
+ current: { id?: string; cwd?: string; name?: string; firstMessage?: string } | null;
101
+ svc: { id: string; cwd: string } | null;
102
+ svcInstalled: boolean;
103
+ opsCache: { sid: string; at: number; etag: string | null; rows: any[] } | null;
104
+ opsInflight: { sid: string; promise: Promise<any[]> } | null;
105
+ } = ((globalThis as any).__aitiGitTraceShared ??= { current: null, svc: null, svcInstalled: false, opsCache: null, opsInflight: null });
106
+
107
+ export function bindSession(props?: any): void {
108
+ const cs = props?.currentSession;
109
+ if (cs && (cs.id || cs.cwd || cs.name)) SHARED.current = { id: cs.id, cwd: cs.cwd, name: cs.name, firstMessage: cs.firstMessage };
110
+ }
111
+
112
+ // ── 统一会话状态服务(pi-web.session,方案 docs/pi-web-session-state-and-perf-plan.md §4)──
113
+ // 仅在 phase=active 时使用 canonical sessionId;**禁止 URL ?session= 未验证兜底**
114
+ //(会话已删除时打出幻影请求)。消费范式:判空降级 + onExtServiceChange 等待。
115
+ function applySessionSnapshot(snap: any): void {
116
+ SHARED.svc = snap && snap.phase === "active" && snap.sessionId
117
+ ? { id: String(snap.sessionId), cwd: String(snap.session?.cwd ?? "") }
118
+ : null;
119
+ }
120
+ /** 挂载统一会话服务订阅(entry.tsx 调用一次;服务缺席时静默降级到 props 通道)。 */
121
+ export function installSessionService(ctx: Record<string, any>): void {
122
+ if (SHARED.svcInstalled) return;
123
+ SHARED.svcInstalled = true;
124
+ const attach = (): boolean => {
125
+ const svc = ctx.getExtService?.("pi-web.session");
126
+ if (!svc?.subscribe) return false;
127
+ svc.subscribe(applySessionSnapshot);
128
+ return true;
129
+ };
130
+ if (!attach()) {
131
+ ctx.onExtServiceChange?.((key: string, phase: string) => {
132
+ if (key === "pi-web.session" && phase === "up") attach();
133
+ });
134
+ }
135
+ }
136
+
137
+ export function getSessionId(): string {
138
+ // 1) 统一服务(active canonical,唯一权威);2) 插槽 props(已解析值);3) 不做 URL 兜底
139
+ if (SHARED.svc?.id) return SHARED.svc.id;
140
+ if (SHARED.current?.id) return String(SHARED.current.id);
141
+ return "";
142
+ }
143
+ export function getCwd(): string {
144
+ return String(SHARED.svc?.cwd || SHARED.current?.cwd || "");
145
+ }
146
+
147
+ export const panelStyle: any = {
148
+ position: "fixed", right: 18, top: 64, zIndex: 10050, width: 460, maxWidth: "92vw",
149
+ maxHeight: "78vh", overflow: "auto", background: "var(--bg-panel)", color: "var(--text)",
150
+ border: "1px solid var(--border)", borderRadius: 10, padding: 14,
151
+ boxShadow: "0 8px 32px rgba(0,0,0,.45)", fontSize: 13, lineHeight: 1.5,
152
+ };
153
+ export const btnStyle: any = {
154
+ cursor: "pointer", borderRadius: 6, border: "1px solid var(--border)",
155
+ background: "var(--bg-panel)", color: "inherit", padding: "3px 10px", fontSize: 12,
156
+ };
157
+ export const chipStyle: any = { ...btnStyle, padding: "1px 7px", fontSize: 11, opacity: 0.9 };
158
+ export const inputStyle: any = {
159
+ borderRadius: 6, border: "1px solid var(--border)", background: "var(--bg)",
160
+ color: "inherit", padding: "4px 8px", fontSize: 12, width: "100%", boxSizing: "border-box",
161
+ };
162
+ /** float 契约滚动层:float 给面板根补 display:flex/column + overflow:hidden,内容区必须自带滚动(docs/aiti-web-ext-float-dialogs.md) */
163
+ export const scrollLayerStyle: any = { flex: 1, minHeight: 0, overflowY: "auto" };
164
+
165
+ export const badgeStyle = (kind: string): any => ({
166
+ cursor: "pointer", marginLeft: 6, padding: "0 6px", borderRadius: 8, fontSize: 11,
167
+ border: "1px solid", ...(kind === "cross" ? { color: "#9a6700", borderColor: "#d4a72c" }
168
+ : kind === "leak" ? { color: "#cf222e", borderColor: "#f1c1c1" }
169
+ : { color: "var(--text-dim)", borderColor: "var(--border)" }),
170
+ });
171
+
172
+ /** 时间格式:今天显示 HH:MM,更早显示日期(文件视图行尾/触达行用) */
173
+ export function fmtTime(ts?: number): string {
174
+ if (!ts) return "";
175
+ const d = new Date(ts);
176
+ return d.toDateString() === new Date().toDateString()
177
+ ? d.toTimeString().slice(0, 5)
178
+ : d.toLocaleDateString();
179
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * aiti-git-trace · client 入口(JSX + TypeScript + vite 构建)
3
+ *
4
+ * 装配(§8.2):
5
+ * - toolbar「⎇ 关注路径」/「⎇ 时间线」
6
+ * - dialogs:关注路径面板 / ops 时间线 / 回撤三选项对话框(全局 Promise API)
7
+ * - banners:回撤后验横幅(兜底版,window.__aitiGitTraceBanner)
8
+ * - userMessageActions:「⎇」徽标(打开时间线)
9
+ *
10
+ * 跨扩展对接点(window):__aitiGitTraceForkDialog(forkGate 未来调用)、__aitiGitTraceBanner(core fork 后调用)
11
+ */
12
+ import { __setCtx } from "./host-shim";
13
+ import { seedDockDefault, declareDockZones, sidebarZoneOf } from "../../../_shared/ext-float-dock";
14
+ // 薄壳版:面板组件名保持不变(内部是懒加载包装),新增 requestForkDialog(用到才拉重 chunk)
15
+ import { WatchToolbarButton, TimelineToolbarButton, TimelineComposerButton, WatchPanelSlot, WatchDockLeft, WatchDockRight, TimelineSlot, TimelineDockRight, TimelineDockLeft, ForkDialogSlot, BannerSlot, MessageBadge, installSessionService, requestForkDialog } from "./trace-app";
16
+ import { TraceFilesRow } from "./trace-files-row";
17
+
18
+ export default function register(ctx: Record<string, any>) {
19
+ __setCtx(ctx);
20
+ // 统一会话状态服务订阅(pi-web.session):仅 active 时用 canonical id;服务缺席降级 props 通道。
21
+ installSessionService(ctx);
22
+
23
+ // aiti-float dock v3(P1/P2 批):时间线默认右栏(用户定调,left⇄right 可切);关注路径不设 default。
24
+ // dock 槽注册专用 face 变体(face 参数保证只有激活面渲染,防 dialogs/dock 双实例同渲)
25
+ seedDockDefault("aiti-git-trace:timeline", "right-panel");
26
+ // zones 进注册表:TabBar ⇱ 菜单(后台 tab)也能看到 left⇄right 全集(v3.1)
27
+ declareDockZones("aiti-git-trace:timeline", ["right-panel", sidebarZoneOf("aiti-git-trace:timeline")], "right-panel");
28
+ declareDockZones("aiti-git-trace:watch", [sidebarZoneOf("aiti-git-trace:watch"), "right-panel"], sidebarZoneOf("aiti-git-trace:watch"));
29
+ ctx.registerItems?.("rightFileDock", [
30
+ { id: "timeline", render: TimelineDockRight, meta: { title: "时间线" } },
31
+ { id: "watch", render: WatchDockRight, meta: { title: "关注路径" } },
32
+ ]);
33
+ ctx.registerItems?.("sidebarExplorerDock", [
34
+ { id: "timeline", render: TimelineDockLeft, meta: { title: "时间线", priority: 3 } },
35
+ { id: "watch", render: WatchDockLeft, meta: { title: "关注路径", priority: 3 } },
36
+ ]);
37
+
38
+ // 工具条独立入口:静态注册 + feature 门控(默认关)——宿主按 feature 统一显隐(关 → 顶栏行/⋯菜单/自定义面板都不进,
39
+ // extman 切换实时生效),扩展零门控代码(参照 session-manager / aiti-acp-agent)
40
+ // ⎇ 关注路径 → trace.watchUI(入口在时间线面板顶部)
41
+ // ⎇ 时间线 → trace.timelineUI(主入口在输入框下方控制行 composerTools 插槽)
42
+ ctx.registerItems?.("toolbar", [
43
+ { id: "aiti-git-trace-watch", render: WatchToolbarButton, meta: { label: "关注路径", icon: "icons/git-branch.svg" }, feature: "trace.watchUI" },
44
+ { id: "aiti-git-trace-timeline", render: TimelineToolbarButton, meta: { label: "时间线", icon: "icons/git-commit-horizontal.svg" }, feature: "trace.timelineUI" },
45
+ ]);
46
+ ctx.registerItems?.("composerTools", [
47
+ { id: "aiti-git-trace-timeline-entry", render: TimelineComposerButton, meta: { label: "时间线" } },
48
+ ]);
49
+ ctx.registerItems?.("dialogs", [
50
+ { id: "aiti-git-trace-watch-dialog", render: WatchPanelSlot },
51
+ { id: "aiti-git-trace-timeline-dialog", render: TimelineSlot },
52
+ { id: "aiti-git-trace-fork-dialog", render: ForkDialogSlot },
53
+ ]);
54
+ ctx.registerItems?.("banners", [{ id: "aiti-git-trace-banner", render: BannerSlot }]);
55
+ ctx.registerItems?.("userMessageActions", [{ id: "aiti-git-trace-badge", render: MessageBadge }]);
56
+ // assistantMessageFooter(2026-09-17):assistant 消息底部 trace 变更文件行
57
+ // (方案 docs/aiti-git-trace-message-files-ext-plan.md)。旧宿主无此插槽 →
58
+ // registerItems 可能抛错/无渲染点,try/catch 静默降级(aiti-ctx-meter 先例)。
59
+ try {
60
+ ctx.registerItems?.("assistantMessageFooter", [
61
+ { id: "aiti-git-trace-footer", render: TraceFilesRow },
62
+ ]);
63
+ } catch { /* 旧宿主:无此插槽,静默无 UI */ }
64
+
65
+ // §5.4 forkGate:编辑重发/fork 前置门——有被追踪操作时弹回撤三选项;弹窗未就绪/无操作则放行
66
+ ctx.registerSessionForkGate?.(async (info: any) => {
67
+ try {
68
+ // 薄壳拆分:gate 触发时才加载重面板(含三选项对话框)——语义与原 window 全局一致。
69
+ const r = await requestForkDialog({
70
+ sessionId: info?.sessionId || "",
71
+ entryId: info?.entryId || "",
72
+ // 方案 §3.3:被裁轮次集合(M 及之后全部用户消息 entryId)——优先按它精确圈定区间(含被编辑轮)
73
+ cutUserEntryIds: Array.isArray(info?.cutUserEntryIds) ? info.cutUserEntryIds.map(String).filter(Boolean) : [],
74
+ });
75
+ return !(r && r.action === "cancel"); // rollback/keep 都放行 fork(回滚已先行落地)
76
+ } catch { return true; }
77
+ });
78
+
79
+ try { console.log("[aiti-git-trace] client 已挂载(关注路径/时间线/回撤对话框/横幅/徽标)"); } catch {}
80
+ return () => {};
81
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * host-shim —— JSX client bundle 的宿主桥(共享模板通用版)。
3
+ *
4
+ * 为什么需要它:浏览器不认识 JSX,扩展 client 要写 JSX 必须经过 esbuild 预编译
5
+ * (vite lib mode)。esbuild 的 JSX 自动运行时(jsx: "automatic")会生成
6
+ * `jsx(type, props)` 调用,默认指向 React。但 pi-web 的扩展 client 不是 React——
7
+ * 宿主经 register(ctx) 传入的是自己的渲染原语(h / useState / useEffect / apiUrl…)。
8
+ *
9
+ * 所以这里用 vite 的 resolve.alias 把 react / react/jsx-runtime 重定向到本 shim:
10
+ * 组件里 import react 的 hooks → 本 shim 的 createElement/useState/… → 委托 ctx。
11
+ * 产物里零 React 引用,宿主保持单渲染实例,npm 生态组件(只要用标准 react hooks)
12
+ * 也能直接打包进来用。
13
+ *
14
+ * 使用:entry.tsx 的 register(ctx) 里第一件事调 `__setCtx(ctx)`(任何 JSX 渲染前)。
15
+ * 需要的字段(apiUrl/t/useRef/useMemo…)按需在这里加一行委托即可。
16
+ */
17
+
18
+ // 模块级 ctx(entry.tsx 的 register 时设置;组件渲染时读取)
19
+ let _ctx: Record<string, unknown> | null = null;
20
+ /**
21
+ * 薄壳拆分后 host-shim 存在多份模块实例(批量包内联的 shell 副本 / panels chunk 自己的
22
+ * 副本 / panels 经相对 import 拉起的 entry 文件副本)——每份实例的 __setCtx 只写自己的
23
+ * `_ctx`。把宿主 ctx 同时挂 globalThis,未注入过的副本按需回读,否则它们拿到的 hooks
24
+ * 全是 undefined(拆包后实测:`v is not a function or its return value is not iterable`)。
25
+ */
26
+ function getCtx(): Record<string, unknown> | null {
27
+ if (_ctx) return _ctx;
28
+ try { return ((globalThis as any).__aitiGitTraceHostCtx as Record<string, unknown>) ?? null; } catch { return null; }
29
+ }
30
+ export function __setCtx(ctx: Record<string, unknown>): void {
31
+ _ctx = ctx;
32
+ try { (globalThis as any).__aitiGitTraceHostCtx = ctx; } catch { /* 隐私模式等:忽略 */ }
33
+ }
34
+
35
+ /** DI 方案 P2:跨扩展客户端服务读取(ctx.getExtService 的 shim 封装;提供方懒加载/缺席 → undefined,消费方必须降级) */
36
+ export function extSvc<T = unknown>(key: string): T | undefined {
37
+ return (getCtx() as any)?.getExtService?.(key) as T | undefined;
38
+ }
39
+
40
+ /** DI 方案 P2:注册跨扩展客户端服务(ctx.registerExtService 的 shim 封装;返回 disposer,旧宿主返回 null) */
41
+ export function regSvc(key: string, impl: unknown): (() => void) | null {
42
+ const c = getCtx() as any;
43
+ return typeof c?.registerExtService === "function" ? c.registerExtService(key, impl) : null;
44
+ }
45
+ function c<T = (...args: never[]) => unknown>(name: string): T {
46
+ return (getCtx()?.[name] as T) ?? (() => undefined as never);
47
+ }
48
+
49
+ // ---------- react 兼容导出(esbuild alias react → 本 shim) ----------
50
+ // React 的 Fragment 类型是 Symbol.for("react.fragment")——传给宿主 h 即生成真 Fragment。
51
+ export const Fragment = (typeof Symbol !== "undefined" && Symbol.for) ? Symbol.for("react.fragment") : 0xacb;
52
+ export function createElement(...args: unknown[]): unknown {
53
+ return c("h").call(null, ...args);
54
+ }
55
+ // 自动运行时 jsx(type, props, key)——key 是独立第三参;宿主 h() = React.createElement
56
+ // 的第三参是 **children**。直接透传会把 key 当子元素(元素 key 丢失 + key 值覆盖
57
+ // props.children 成为真实内容)。因此必须把 key 合并回 props。
58
+ function mergeKey(args: unknown[]): unknown[] {
59
+ const [type, props, key, ...rest] = args;
60
+ if (props && typeof props === "object") {
61
+ // key 一律合并进 props,createElement 恒收 (type, config) 两参:
62
+ // 有 key → config.key 由 React 读为元素 key;key 为 undefined/null → 也必须剥掉第三参——
63
+ // 否则 createElement(type, config, undefined) 按 legacy 语义(第三参=children)用 undefined
64
+ // 覆盖 props.children,元素内容整体丢失(实证:aiti-editor ⋯ 菜单 key=undefined 的项全部空白)。
65
+ const merged: Record<string, unknown> = { ...(props as Record<string, unknown>) };
66
+ if (key !== undefined && key !== null) merged.key = key;
67
+ return [type, merged, ...rest];
68
+ }
69
+ return args;
70
+ }
71
+ export const jsx = (...args: unknown[]) => createElement(...mergeKey(args));
72
+ export const jsxs = (...args: unknown[]) => {
73
+ // React 自动运行时语义:jsxs(静态多子元素)创建时会对每个子元素调用
74
+ // validateChildKeys → 标记 _store.validated = 1("已校验,勿再告警")。宿主 h()
75
+ // 是 React.createElement,但它只创建元素、不做这个标记——不补的话,
76
+ // `<div><A/><B/></div>` 这类“静态兄弟元素”(JSX 里无需写 key,React 自动运行时
77
+ // 本应抑制该警告)会被 React 误报 “Each child in a list should have a unique key”
78
+ // (dev-only,按 owner 去重一次)。值必须是 1(2 是另一特殊标记,仍会告警)。
79
+ const props = (args as [unknown, { children?: unknown } | undefined, ...unknown[]])[1];
80
+ const children = props?.children;
81
+ if (Array.isArray(children)) {
82
+ for (const c of children) {
83
+ if (c && typeof c === "object" && (c as { _store?: { validated?: number } })._store) {
84
+ (c as { _store: { validated: number } })._store.validated = 1;
85
+ }
86
+ }
87
+ }
88
+ return createElement(...mergeKey(args));
89
+ };
90
+
91
+ // ---------- React hooks 委托(按需增删) ----------
92
+ export const useState = <T>(init: T | (() => T)) => c<(...a: unknown[]) => [T, (v: T | ((prev: T) => T)) => void]>("useState")(init);
93
+ export const useEffect = (fn: () => unknown, deps?: unknown[]) => c("useEffect")(fn, deps);
94
+ export const useRef = <T>(v: T) => c<(...a: unknown[]) => { current: T }>("useRef")(v);
95
+ export const useCallback = <T extends (...args: never[]) => unknown>(fn: T, deps?: unknown[]) => c<(...a: unknown[]) => T>("useCallback")(fn, deps);
96
+ export const useMemo = <T>(fn: () => T, deps?: unknown[]) => c<(...a: unknown[]) => T>("useMemo")(fn, deps);
97
+
98
+ // ---------- 宿主工具委托(按需增删) ----------
99
+ /** apiUrl(p):把相对路径拼成当前 basePath 下的完整 API URL(多实例 /dev 前缀安全)。 */
100
+ export function apiUrl(p: string): string {
101
+ const fn = getCtx()?.apiUrl as ((p: string) => string) | undefined;
102
+ return typeof fn === "function" ? fn(p) : p;
103
+ }
104
+ /** t(key, params?):扩展 i18n 翻译(index.ts ui.i18n 里的词条)。 */
105
+ export function t(key: string, params?: Record<string, unknown>): string {
106
+ const fn = getCtx()?.t as ((key: string, params?: Record<string, unknown>) => string) | undefined;
107
+ return typeof fn === "function" ? fn(key, params) : key;
108
+ }
109
+ /** appendText(text):把文本注入 AI 输入框(plan-first「一键插入」同通道;旧宿主无此方法 → 静默跳过)。 */
110
+ export function appendText(text: string): void {
111
+ const fn = getCtx()?.appendText as ((text: string) => void) | undefined;
112
+ try { if (typeof fn === "function") fn(text); } catch { /* 宿主未就绪时忽略 */ }
113
+ }
114
+ /** openFile(path, fileName?):右侧文件标签页打开(ctx.openFile,与 aiti-doc-center / aiti-folder 同款 dock;旧宿主无此方法 → 静默跳过)。 */
115
+ export function openFile(path: string, fileName?: string): void {
116
+ const fn = getCtx()?.openFile as ((path: string, fileName?: string) => void) | undefined;
117
+ try { if (typeof fn === "function") fn(path, fileName); } catch { /* 宿主未就绪时忽略 */ }
118
+ }
@@ -0,0 +1,2 @@
1
+ // esbuild 自动 JSX runtime 别名目标:jsx/jsxs/Fragment 全委托 host-shim
2
+ export { jsx, jsxs, Fragment } from "./host-shim";