@konnga/dsh-hud 0.9.1 → 0.9.2

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.
@@ -0,0 +1,188 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * git 的调用与解析:执行 git、取 blob 字节、把 sha/路径归一成仓库内的提交与文件。
6
+ */
7
+ /** 本地 git 超时:大仓库的 `git status` 也可能要几秒。 */
8
+ export const GIT_TIMEOUT_MS = 30_000;
9
+ /** execFile 输出上限,防止异常仓库把内存吃满。 */
10
+ export const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
11
+ /** 同一 cwd 的状态缓存时长。 */
12
+ export const MAX_PATH_LENGTH = 4_096;
13
+ /** 请求体上限。 */
14
+ /** 控制字符(含 NUL、换行)——出现在路径里即视为非法入参。 */
15
+ export const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
16
+ /** 运行一条 git 命令;永不自造结果,失败就把失败原因交回调用方。 */
17
+ export function runGit(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
18
+ return new Promise((resolve) => {
19
+ execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES, windowsHide: true, encoding: 'utf8' }, (error, stdout, stderr) => {
20
+ if (!error) {
21
+ resolve({ ok: true, stdout: stdout || '' });
22
+ return;
23
+ }
24
+ resolve({
25
+ ok: false,
26
+ stdout: stdout || '',
27
+ stderr: stderr || '',
28
+ killed: Boolean(error.killed) || Boolean(error.signal),
29
+ code: typeof error.code === 'number' ? error.code : (error.code ?? null),
30
+ message: error instanceof Error ? error.message : String(error),
31
+ });
32
+ });
33
+ });
34
+ }
35
+ /** 校验浏览器传来的 cwd,只接受绝对、无控制字符、长度有界的路径。 */
36
+ export function validateCwd(value) {
37
+ if (typeof value !== 'string' || value.length === 0)
38
+ return { ok: false, code: 'invalid-cwd', message: 'cwd must be a non-empty string' };
39
+ if (value.length > MAX_PATH_LENGTH)
40
+ return { ok: false, code: 'invalid-cwd', message: 'cwd is too long' };
41
+ if (CONTROL_CHARS.test(value))
42
+ return { ok: false, code: 'invalid-cwd', message: 'cwd contains control characters' };
43
+ if (!path.isAbsolute(value))
44
+ return { ok: false, code: 'invalid-cwd', message: 'cwd must be an absolute path' };
45
+ return { ok: true, value };
46
+ }
47
+ /** 提交 sha 的形状:只用十六进制,长度 4..40(面板给的是短 sha)。 */
48
+ export const COMMIT_SHA = /^[0-9a-fA-F]{4,40}$/;
49
+ /**
50
+ * 校验提交 sha。
51
+ *
52
+ * 只收十六进制,因此 `HEAD~1`、`--help`、`origin/main` 这类都进不来——`sha` 会作为
53
+ * 位置参数交给 `git`,只靠 `--` 分隔路径是不够的,必须在入口就把选项形状挡掉。
54
+ */
55
+ export function validateCommitSha(value, label = 'sha') {
56
+ const reject = (message) => ({
57
+ ok: false,
58
+ code: 'invalid-sha',
59
+ message: `${label}: ${message}`,
60
+ });
61
+ if (typeof value !== 'string' || value.length === 0)
62
+ return reject('must be a non-empty string');
63
+ if (!COMMIT_SHA.test(value))
64
+ return reject('must be 4..40 hex characters');
65
+ return { ok: true, value };
66
+ }
67
+ /** 校验 diff 的仓库内相对路径:必须是相对、无控制字符、无 `..`、不以 `-` 开头。 */
68
+ export function validateDiffPath(value, label = 'path') {
69
+ const reject = (message) => ({
70
+ ok: false,
71
+ code: 'invalid-path',
72
+ message: `${label}: ${message}`,
73
+ });
74
+ if (typeof value !== 'string' || value.length === 0)
75
+ return reject('must be a non-empty string');
76
+ if (value.length > MAX_PATH_LENGTH)
77
+ return reject('is too long');
78
+ if (CONTROL_CHARS.test(value))
79
+ return reject('contains control characters');
80
+ if (path.isAbsolute(value))
81
+ return reject('must be repo-relative, not absolute');
82
+ if (/^[\\/]/.test(value))
83
+ return reject('must not start with a separator');
84
+ if (value.startsWith('-'))
85
+ return reject('must not start with "-"');
86
+ if (value.split(/[\\/]/).includes('..'))
87
+ return reject('must not contain ".."');
88
+ return { ok: true, value };
89
+ }
90
+ /**
91
+ * 解析仓库根与目标提交的元信息(含父提交),`readCommit` / `readCommitDiff` 共用。
92
+ *
93
+ * 父提交直接从 `%P` 拿:`git log -1` 已经把它给了出来,不必再跑一次 `rev-parse`。
94
+ * 取**第一个**父提交作为比较基准——"这次提交改了什么"对合并提交也是这个意思,
95
+ * 而 `git show` 对合并提交默认给的是"组合差异",常常是空的,对用户没意义。
96
+ *
97
+ * @returns `{ repo, sha, shortSha, subject, author, date, committedAt, refs, parents, base }`;
98
+ * `base` 为空串表示这是根提交(与空树比较)。非仓库时 `repo` 为 null。
99
+ */
100
+ export async function resolveCommit(cwd, sha) {
101
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel']);
102
+ if (!root.ok) {
103
+ if (root.killed)
104
+ throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' });
105
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd))
106
+ throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' });
107
+ if (root.code === 'ENOENT')
108
+ throw Object.assign(new Error('git executable not found'), { code: 'git-missing' });
109
+ return {
110
+ repo: null,
111
+ sha,
112
+ shortSha: sha,
113
+ subject: '',
114
+ author: '',
115
+ date: '',
116
+ committedAt: null,
117
+ refs: [],
118
+ parents: [],
119
+ base: null,
120
+ };
121
+ }
122
+ const repoRoot = root.stdout.split('\n')[0].trim();
123
+ if (!repoRoot)
124
+ return {
125
+ repo: null,
126
+ sha,
127
+ shortSha: sha,
128
+ subject: '',
129
+ author: '',
130
+ date: '',
131
+ committedAt: null,
132
+ refs: [],
133
+ parents: [],
134
+ base: null,
135
+ };
136
+ const result = await runGit(repoRoot, [
137
+ 'log',
138
+ '-1',
139
+ '--no-color',
140
+ '--date=short',
141
+ '--pretty=format:%H%x00%h%x00%an%x00%ad%x00%ct%x00%D%x00%P%x00%s',
142
+ sha,
143
+ ]);
144
+ if (!result.ok) {
145
+ throw Object.assign(new Error(result.stderr.trim() || 'unknown commit'), {
146
+ code: result.killed ? 'timeout' : 'unknown-commit',
147
+ });
148
+ }
149
+ const [full, short, author, date, seconds, refs, parents, subject] = result.stdout.split('\0');
150
+ const at = Number(seconds);
151
+ const parentList = typeof parents === 'string' && parents.length > 0 ? parents.split(' ').filter(Boolean) : [];
152
+ return {
153
+ repo: repoRoot,
154
+ sha: full || sha,
155
+ shortSha: short || sha,
156
+ subject: subject ?? '',
157
+ author: author ?? '',
158
+ date: date ?? '',
159
+ committedAt: Number.isFinite(at) ? at * 1000 : null,
160
+ refs: refs ? refs.split(', ').filter(Boolean) : [],
161
+ parents: parentList,
162
+ base: parentList.length > 0 ? parentList[0] : null,
163
+ };
164
+ }
165
+ /**
166
+ * `runGit` 的字节版。
167
+ *
168
+ * `cat-file blob` 的输出是任意二进制,按 utf8 解码会毁掉它(非法序列变成 U+FFFD,
169
+ * 再编码回去就不是原文件了),所以这条路径必须单独走 buffer 编码。
170
+ */
171
+ export function runGitBytes(cwd, args, maxBytes) {
172
+ return new Promise((resolve) => {
173
+ execFile('git', args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: maxBytes, windowsHide: true, encoding: 'buffer' }, (error, stdout, stderr) => {
174
+ if (!error) {
175
+ resolve({ ok: true, stdout });
176
+ return;
177
+ }
178
+ resolve({
179
+ ok: false,
180
+ stdout,
181
+ stderr: stderr ? stderr.toString('utf8') : '',
182
+ killed: Boolean(error.killed) || Boolean(error.signal),
183
+ code: typeof error.code === 'number' ? error.code : (error.code ?? null),
184
+ message: error instanceof Error ? error.message : String(error),
185
+ });
186
+ });
187
+ });
188
+ }
@@ -0,0 +1,265 @@
1
+ import { resolveCommit, runGit, runGitBytes } from './git.js';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * 图片改动:按文件头认图片、读两侧尺寸与字节、把不可预览的原因说清楚。
6
+ */
7
+ /**
8
+ * 单侧图片预览的字节上限。
9
+ *
10
+ * 超限不等于"看不了这个改动":仍然回报真实字节数,只是不把内容塞进 JSON
11
+ * (base64 还要再涨 1/3,RPC 体量必须有界)。
12
+ */
13
+ export const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
14
+ /** PNG 的签名(8 字节)。 */
15
+ export const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
16
+ /**
17
+ * 从**内容**而不是扩展名认图片类型(扩展名会骗人:`.png` 里放 gif 很常见)。
18
+ *
19
+ * @param buffer - 文件头若干字节。
20
+ * @returns MIME 字符串,认不出返回 null。
21
+ */
22
+ export function sniffImageMime(buffer) {
23
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12)
24
+ return null;
25
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE))
26
+ return 'image/png';
27
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
28
+ return 'image/jpeg';
29
+ const gif = buffer.subarray(0, 6).toString('latin1');
30
+ if (gif === 'GIF87a' || gif === 'GIF89a')
31
+ return 'image/gif';
32
+ if (buffer.subarray(0, 4).toString('latin1') === 'RIFF' &&
33
+ buffer.subarray(8, 12).toString('latin1') === 'WEBP')
34
+ return 'image/webp';
35
+ if (buffer.subarray(0, 2).toString('latin1') === 'BM')
36
+ return 'image/bmp';
37
+ return null;
38
+ }
39
+ /** PNG:IHDR 紧跟签名,宽高各 4 字节大端。 */
40
+ export function pngSize(buffer) {
41
+ if (buffer.length < 24)
42
+ return null;
43
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
44
+ }
45
+ /** GIF:逻辑屏幕描述符,宽高各 2 字节小端。 */
46
+ export function gifSize(buffer) {
47
+ if (buffer.length < 10)
48
+ return null;
49
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
50
+ }
51
+ /**
52
+ * JPEG:从头扫段,找 SOFn(C0–CF,但 C4/C8/CC 不是)里的高/宽。
53
+ *
54
+ * 段长字段是**含自身两字节**的长度,所以下一段起点是 `index + 2 + length`;
55
+ * 填充 0xFF、TEM 与 RSTn 都没有长度字段,只前进一字节。
56
+ */
57
+ export function jpegSize(buffer) {
58
+ let index = 2;
59
+ while (index + 9 < buffer.length) {
60
+ if (buffer[index] !== 0xff) {
61
+ index += 1;
62
+ continue;
63
+ }
64
+ const marker = buffer[index + 1];
65
+ if (marker === 0xff || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
66
+ index += 1;
67
+ continue;
68
+ }
69
+ const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
70
+ if (isFrame)
71
+ return { width: buffer.readUInt16BE(index + 7), height: buffer.readUInt16BE(index + 5) };
72
+ const length = buffer.readUInt16BE(index + 2);
73
+ if (length < 2)
74
+ return null;
75
+ index += 2 + length;
76
+ }
77
+ return null;
78
+ }
79
+ /** WebP:VP8X 是 24 位减一、VP8(有损)内嵌 14 位、VP8L(无损)打包在 32 位里。 */
80
+ export function webpSize(buffer) {
81
+ if (buffer.length < 30)
82
+ return null;
83
+ const chunk = buffer.subarray(12, 16).toString('latin1');
84
+ if (chunk === 'VP8X')
85
+ return { width: buffer.readUIntLE(24, 3) + 1, height: buffer.readUIntLE(27, 3) + 1 };
86
+ if (chunk === 'VP8 ' && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) {
87
+ return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
88
+ }
89
+ if (chunk === 'VP8L' && buffer[20] === 0x2f) {
90
+ const packed = buffer.readUInt32LE(21);
91
+ return { width: (packed & 0x3fff) + 1, height: ((packed >> 14) & 0x3fff) + 1 };
92
+ }
93
+ return null;
94
+ }
95
+ /** BMP:BITMAPINFOHEADER 的宽为有符号、高可为负(自上而下)。 */
96
+ export function bmpSize(buffer) {
97
+ if (buffer.length < 26)
98
+ return null;
99
+ const width = buffer.readInt32LE(18);
100
+ const height = buffer.readInt32LE(22);
101
+ if (width <= 0 || height === 0)
102
+ return null;
103
+ return { width, height: Math.abs(height) };
104
+ }
105
+ /**
106
+ * 读出图片的像素尺寸。纯函数,只读头部,认不出返回 `null`(绝不抛)。
107
+ *
108
+ * @param buffer - 文件内容(头部足够即可,但这里拿的是完整内容)。
109
+ * @param mime - `sniffImageMime` 的结果。
110
+ * @returns `{ width, height }` 或 null。
111
+ */
112
+ export function readImageSize(buffer, mime) {
113
+ try {
114
+ if (mime === 'image/png')
115
+ return pngSize(buffer);
116
+ if (mime === 'image/jpeg')
117
+ return jpegSize(buffer);
118
+ if (mime === 'image/gif')
119
+ return gifSize(buffer);
120
+ if (mime === 'image/webp')
121
+ return webpSize(buffer);
122
+ if (mime === 'image/bmp')
123
+ return bmpSize(buffer);
124
+ return null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /** 一个 git 对象的字节数;对象不存在(新文件还没有索引副本)返回 null。 */
131
+ export async function gitBlobSize(repoRoot, spec) {
132
+ const result = await runGit(repoRoot, ['cat-file', '-s', spec]);
133
+ if (!result.ok)
134
+ return null;
135
+ const size = Number.parseInt(result.stdout.trim(), 10);
136
+ return Number.isFinite(size) && size >= 0 ? size : null;
137
+ }
138
+ /** 判断一侧的内容:能预览就带上 base64 与像素尺寸,否则只说"为什么不能"。 */
139
+ export function describeSide(buffer) {
140
+ const bytes = buffer.length;
141
+ const mime = sniffImageMime(buffer);
142
+ if (mime === null)
143
+ return { kind: 'not-image', bytes };
144
+ const size = readImageSize(buffer, mime);
145
+ return {
146
+ kind: 'image',
147
+ mime,
148
+ bytes,
149
+ width: size === null ? null : size.width,
150
+ height: size === null ? null : size.height,
151
+ base64: buffer.toString('base64'),
152
+ };
153
+ }
154
+ /** 读工作区里的一个文件;不是普通文件、或超过上限,都只回报状态。 */
155
+ export async function readWorktreeSide(repoRoot, file) {
156
+ const full = path.resolve(repoRoot, file);
157
+ const rootPrefix = repoRoot.endsWith(path.sep) ? repoRoot : repoRoot + path.sep;
158
+ // validateDiffPath 已经挡掉绝对路径与 `..`;这里再确认落在仓库内(软链接不越界)。
159
+ if (!full.startsWith(rootPrefix))
160
+ return { kind: 'missing' };
161
+ let stats;
162
+ try {
163
+ stats = await fs.promises.stat(full);
164
+ }
165
+ catch {
166
+ return { kind: 'missing' };
167
+ }
168
+ if (!stats.isFile())
169
+ return { kind: 'missing' };
170
+ if (stats.size > MAX_IMAGE_BYTES)
171
+ return { kind: 'too-large', bytes: stats.size, limit: MAX_IMAGE_BYTES };
172
+ try {
173
+ return describeSide(await fs.promises.readFile(full));
174
+ }
175
+ catch {
176
+ return { kind: 'missing' };
177
+ }
178
+ }
179
+ /** 读一个 git 对象(`<rev>:<path>`,rev 为空串即索引):不存在即 missing,超限只回报字节数。 */
180
+ export async function readGitSide(repoRoot, rev, file) {
181
+ const spec = `${rev}:${file}`;
182
+ const size = await gitBlobSize(repoRoot, spec);
183
+ if (size === null)
184
+ return { kind: 'missing' };
185
+ if (size > MAX_IMAGE_BYTES)
186
+ return { kind: 'too-large', bytes: size, limit: MAX_IMAGE_BYTES };
187
+ const result = await runGitBytes(repoRoot, ['cat-file', 'blob', spec], MAX_IMAGE_BYTES + 64 * 1024);
188
+ if (!result.ok) {
189
+ if (result.killed)
190
+ throw Object.assign(new Error('git cat-file timed out'), { code: 'timeout' });
191
+ return { kind: 'missing' };
192
+ }
193
+ return describeSide(result.stdout);
194
+ }
195
+ /**
196
+ * 读一个二进制(图片)改动的两侧字节与像素尺寸,全部只读。
197
+ *
198
+ * `side` 与文本 diff 的两枚页签一一对应,这样"未暂存 / 已暂存"在两个视图里指的是同一件事:
199
+ * - `worktree`(未暂存):基线 = 索引 `:<path>`,新 = 工作区文件;
200
+ * - `index`(已暂存):基线 = `HEAD`,新 = 索引 `:<path>`;
201
+ * - 给了 `sha`(提交明细):基线 = 第一父提交(根提交则为空),新 = 该提交。
202
+ *
203
+ * 两侧各自回答"能不能预览":`kind` 为 `image` / `too-large` / `not-image` / `missing`。
204
+ * 任一侧不可预览都不算失败 —— 另一侧照样能看。
205
+ *
206
+ * @param cwd - 会话工作区(绝对路径)。
207
+ * @param file - 仓库内相对路径。
208
+ * @param origFile - 重命名/复制的原路径,可选。
209
+ * @param side - `'worktree'` | `'index'`,给了 sha 时忽略。
210
+ * @param sha - 提交 sha(提交明细用),可选。
211
+ * @returns `{ repo, path, origPath, side, sha, before, after }`。
212
+ */
213
+ export async function readImagePair(cwd, file, origFile = null, side = 'worktree', sha = null) {
214
+ const base = {
215
+ path: file,
216
+ origPath: origFile ?? null,
217
+ side: sha === null ? side : 'commit',
218
+ sha,
219
+ };
220
+ const empty = {
221
+ repo: null,
222
+ ...base,
223
+ before: { kind: 'missing' },
224
+ after: { kind: 'missing' },
225
+ };
226
+ if (sha !== null) {
227
+ const commit = await resolveCommit(cwd, sha);
228
+ if (!commit.repo)
229
+ return empty;
230
+ return {
231
+ repo: commit.repo,
232
+ shortSha: commit.shortSha,
233
+ ...base,
234
+ before: commit.base === null
235
+ ? { kind: 'missing' }
236
+ : await readGitSide(commit.repo, commit.base, origFile ?? file),
237
+ after: await readGitSide(commit.repo, commit.sha, file),
238
+ };
239
+ }
240
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel']);
241
+ if (!root.ok) {
242
+ if (root.killed)
243
+ throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' });
244
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd))
245
+ throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' });
246
+ if (root.code === 'ENOENT')
247
+ throw Object.assign(new Error('git executable not found'), { code: 'git-missing' });
248
+ return empty;
249
+ }
250
+ const repoRoot = root.stdout.split('\n')[0].trim();
251
+ if (side === 'index') {
252
+ return {
253
+ repo: repoRoot,
254
+ ...base,
255
+ before: await readGitSide(repoRoot, 'HEAD', origFile ?? file),
256
+ after: await readGitSide(repoRoot, '', file),
257
+ };
258
+ }
259
+ return {
260
+ repo: repoRoot,
261
+ ...base,
262
+ before: await readGitSide(repoRoot, '', origFile ?? file),
263
+ after: await readWorktreeSide(repoRoot, file),
264
+ };
265
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * dsh-hud — 宿主半边(Cordis 插件)。
3
+ *
4
+ * 在自己的 `ctx.webServer` 上挂 `/dsh-hud-rpc` 前缀路由,收发与内置 `/api` 完全相同的
5
+ * Connection RPC 信封,并把请求围栏交给 `connection.requestRejection`
6
+ * (Host/Origin + 浏览器会话认证),因此信任等级与内置通道一致。
7
+ *
8
+ * 为什么不直接调用内置通道:dsh >= 0.1.5-rc.1 起,外部插件无法使用
9
+ * `ctx.connection.rpc.handle()` —— Connection 插件是在自己内部
10
+ * `ctx.inject(["webServer"], …)` 作用域里注册路由的,无论调用方 inject 了什么,
11
+ * 那一行都会以 `cannot get property "webServer" without inject` 挂载失败。
12
+ * 因此本插件自持路由,只借用 Connection 的信任判定。
13
+ *
14
+ * 端点(全部只读,不写 index / 工作区 / 配置):
15
+ * - `status { cwd }` → 仓库事实:当前分支(或 detached)、ahead/behind、脏状态、
16
+ * 按类型分组的改动计数,以及改动清单(porcelain-v2 的两个字母 + 路径)。
17
+ * - `diff { cwd, path, origPath? }` → 一个改动文件的**两侧** unified diff
18
+ * (工作区 / 已暂存),已解析成带行号的 rows;未跟踪文件与 `--no-index` 对齐,
19
+ * 文本按行截断、行数有上限,二进制只报 `binary`。
20
+ * - `image { cwd, path, origPath?, side?, sha? }` → 图片改动**两侧**的字节与像素尺寸
21
+ * (`kind` 为 `image` / `too-large` / `not-image` / `missing`),给二进制预览用;
22
+ * 单侧上限 `MAX_IMAGE_BYTES`,超限只回报字节数。
23
+ * - `session-time { sessionId }` → 该会话的创建时刻与累计墙钟(`llmMs` / `toolMs` /
24
+ * `turns` / `steps`,来自内置 `sessionStats` 投影;服务缺失时 `available: false`)。
25
+ * - `prompt-history { sessionId, limit? }` → 该会话的用户 prompt(**新的在前**,来自内置
26
+ * `turnOutline` 投影;相邻重复只留一条,默认最多 100 条)。
27
+ * - `log { cwd, count? }` → 最近提交(短 sha、作者、日期、refs、标题),条数钳制 1..50。
28
+ * - `commit { cwd, sha }` → 某次提交的元信息与改动清单(相对**第一个父提交**,
29
+ * 合并提交也按第一父提交看),清单形状与 `status.changes` 一致。
30
+ * - `commit-diff { cwd, sha, path, origPath? }` → 该提交里某个文件的 unified diff,
31
+ * 已解析成带行号的 rows(形状与 `diff` 的**单侧**一致)。
32
+ * - `session-changes { cwd, sessionId }` → **本会话改过哪些文件**(按会话归因,见下):
33
+ * 路径、写入/编辑次数、首次与末次改动时间,以及"自 … 起记录"的时间戳。
34
+ *
35
+ * git 调用一律 `execFile`(无 shell)、带超时、有界输出,并对同一 cwd 做短 TTL 缓存 +
36
+ * 在途请求合并,避免面板轮询时反复跑 `git status`。
37
+ */
38
+ export { parseDiff, parseNameStatus, parseStatus } from './parse.js';
39
+ export { validateCommitSha, validateCwd, validateDiffPath } from './git.js';
40
+ export { MAX_IMAGE_BYTES, readImagePair, readImageSize, sniffImageMime } from './image.js';
41
+ export { readCommit, readCommitDiff, readDiff, readLog, readReviewSignatures, readStatus, readTags, } from './readers.js';
42
+ import { trackSessionChanges, } from './session.js';
43
+ export { PROMPT_HISTORY_MAX, readPromptHistory, readSessionChanges, readSessionOverview, readSessionTime, readSessionTools, } from './session.js';
44
+ import { CHANNEL, handleRequest, } from './rpc.js';
45
+ export { CHANNEL } from './rpc.js';
46
+ import { rememberContext, } from './services.js';
47
+ /** 本插件独占的 RPC 通道前缀(浏览器侧用同一常量调用)。 */
48
+ /** 插件名:`cordis.patch.yml` 用包名插入,Cordis 读的就是这个导出。 */
49
+ export const name = 'dsh-hud';
50
+ /**
51
+ * 模块自带的硬依赖声明:Cordis 读这里的 `inject` 做激活门控,
52
+ * `cordis.patch.yml` 的行里因此不再重复写一遍。
53
+ */
54
+ export const inject = ['webServer', 'connection'];
55
+ /* --------------------------------------------- 二进制预览(图片,只读) */
56
+ /* ------------------------------------------------- 会话改动(按会话归因) */
57
+ /**
58
+ * "哪个会话写过哪个文件"——事实源是 fs seam 派发的两类事件:
59
+ *
60
+ * - `fs/write-intent` / `fs/edit-intent`(waterfall,参数 `(target, exec)`):这次**要**动
61
+ * 哪个文件。`dsh-tool-fs` 的写入/编辑工具与 `dsh-tool-str-replace-editor` 都走 `ctx.fs`,
62
+ * 因此都会发这两个意图事件。
63
+ * - `fs/observed`(emit,参数 `(target, observation, exec)`):那一下**之后**文件是 `present`
64
+ * 还是 `absent`。注意它对**读取**也发("编辑前必须先读"那个策略插件就是靠它),所以不能
65
+ * 只看它 —— 必须与意图配对:同一个工具调用里先有意图、后有观察,才算这次改动落了地。
66
+ *
67
+ * `exec.agent.session.header` 同时给出 `id`(哪个会话)与 `cwd`(哪个工作区),所以"谁改的"
68
+ * 不用猜。只记 cwd 之内的路径(git 只关心这些),每个会话与全局都有上限,避免长会话吃内存。
69
+ *
70
+ * 已知不覆盖(界面上如实标注,不假装全知):
71
+ * - agent 通过 bash 间接改的文件(`sed -i`、脚本、格式化、`git` 操作);
72
+ * - 本进程启动**之前**的改动(fs 观察不持久化)——所以每个会话都报一个 `since`。
73
+ */
74
+ /* ------------------------------------------------------------------ 线路层 */
75
+ /* ------------------------------------------------------------------- 插件 */
76
+ /**
77
+ * 插件入口。
78
+ * @param ctx - 已注入 webServer 与 connection 的 Cordis 上下文。
79
+ */
80
+ export function apply(ctx) {
81
+ rememberContext(ctx);
82
+ trackSessionChanges(ctx);
83
+ ctx.effect(() => ctx.webServer.register({
84
+ kind: 'prefix',
85
+ path: CHANNEL,
86
+ handler: (req, res) => handleRequest(ctx, req, res),
87
+ }), `dsh-hud: POST ${CHANNEL}/*`);
88
+ }
89
+ // 导出即插件面:`apply` / `inject` / `name` 供 Cordis 读取;`CHANNEL` / `parseStatus` /
90
+ // `validateCwd` / `readStatus` 是可直接单测的单元(前两个是纯函数,最后一个需要真实仓库)。