@konnga/dsh-hud 0.9.0 → 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,317 @@
1
+ import { validateCommitSha, validateCwd, validateDiffPath } from './git.js';
2
+ import { readImagePair } from './image.js';
3
+ import { readCommit, readCommitDiff, readDiff, readLog, readReviewSignatures, readStatusCached, readTags, } from './readers.js';
4
+ import { readPromptHistory, readSessionChanges, readSessionOverview, readSessionTime, readSessionTools, } from './session.js';
5
+ /**
6
+ * 线路层:把 /dsh-hud-rpc 的请求按端点分发、复用 Connection 的信任判定、统一错误信封。
7
+ */
8
+ export const CHANNEL = '/dsh-hud-rpc';
9
+ export const MAX_BODY_BYTES = 256 * 1024;
10
+ /** 一次最多给多少个改动文件算"这一版"的签名(超出的那些只显示"未审",不报错)。 */
11
+ export const REVIEW_SIGNATURE_MAX = 200;
12
+ const toArgs = (args) => args !== null && typeof args === 'object' ? args : {};
13
+ /** 端点表:全部只读;新增端点时在这里登记。 */
14
+ export const ENDPOINTS = {
15
+ async status(args) {
16
+ const input = toArgs(args);
17
+ const checked = validateCwd(input.cwd);
18
+ if (!checked.ok)
19
+ throw Object.assign(new Error(checked.message), { code: checked.code });
20
+ const status = await readStatusCached(checked.value);
21
+ // 「审阅进度」要的"这一版"签名:只在客户端明确要的时候才算(胶囊那一路的轮询不用付这个钱)。
22
+ if (input.review === true && Array.isArray(status.changes) && status.changes.length > 0) {
23
+ status.review = await readReviewSignatures(checked.value, status.changes.slice(0, REVIEW_SIGNATURE_MAX).map((change) => change.file));
24
+ }
25
+ return status;
26
+ },
27
+ async 'session-tools'(args) {
28
+ const input = toArgs(args);
29
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null;
30
+ return readSessionTools(sessionId);
31
+ },
32
+ async 'session-statuses'() {
33
+ return readSessionOverview();
34
+ },
35
+ /** 一个会话的 prompt 历史(新的在前)—— 给输入框的 `↑` 召回用。 */
36
+ async 'prompt-history'(args) {
37
+ const input = toArgs(args);
38
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null;
39
+ return readPromptHistory(sessionId, input.limit);
40
+ },
41
+ /** 一个会话的运行时长(挂钟 + 模型/工具墙钟)—— 给会话计时胶囊用。 */
42
+ async 'session-time'(args) {
43
+ const input = toArgs(args);
44
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null;
45
+ return readSessionTime(sessionId);
46
+ },
47
+ async diff(args) {
48
+ const input = toArgs(args);
49
+ const cwd = validateCwd(input.cwd);
50
+ if (!cwd.ok)
51
+ throw Object.assign(new Error(cwd.message), { code: cwd.code });
52
+ const file = validateDiffPath(input.path);
53
+ if (!file.ok)
54
+ throw Object.assign(new Error(file.message), { code: file.code });
55
+ let origFile = null;
56
+ if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
57
+ const checked = validateDiffPath(input.origPath, 'origPath');
58
+ if (!checked.ok)
59
+ throw Object.assign(new Error(checked.message), { code: checked.code });
60
+ origFile = checked.value;
61
+ }
62
+ return readDiff(cwd.value, file.value, origFile);
63
+ },
64
+ /**
65
+ * 图片预览:一个二进制改动两侧的字节与像素尺寸。
66
+ *
67
+ * `diff` 对二进制只回一句 `binary: true`(文本 diff 里没有可看的东西),
68
+ * 浏览器半靠这个端点把"改了哪张图、尺寸变没变"画出来。只读,不写 index / 工作区。
69
+ */
70
+ async image(args) {
71
+ const input = toArgs(args);
72
+ const cwd = validateCwd(input.cwd);
73
+ if (!cwd.ok)
74
+ throw Object.assign(new Error(cwd.message), { code: cwd.code });
75
+ const file = validateDiffPath(input.path);
76
+ if (!file.ok)
77
+ throw Object.assign(new Error(file.message), { code: file.code });
78
+ let origFile = null;
79
+ if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
80
+ const checked = validateDiffPath(input.origPath, 'origPath');
81
+ if (!checked.ok)
82
+ throw Object.assign(new Error(checked.message), { code: checked.code });
83
+ origFile = checked.value;
84
+ }
85
+ let sha = null;
86
+ if (input.sha !== undefined && input.sha !== null && input.sha !== '') {
87
+ const checked = validateCommitSha(input.sha);
88
+ if (!checked.ok)
89
+ throw Object.assign(new Error(checked.message), { code: checked.code });
90
+ sha = checked.value;
91
+ }
92
+ return readImagePair(cwd.value, file.value, origFile, input.side === 'index' ? 'index' : 'worktree', sha);
93
+ },
94
+ async log(args) {
95
+ const input = toArgs(args);
96
+ const cwd = validateCwd(input.cwd);
97
+ if (!cwd.ok)
98
+ throw Object.assign(new Error(cwd.message), { code: cwd.code });
99
+ return readLog(cwd.value, input.count);
100
+ },
101
+ /** 标签列表(只读)。点一个 tag 复用 `commit` 那条管道去看它指向的提交。 */
102
+ async tags(args) {
103
+ const input = toArgs(args);
104
+ const cwd = validateCwd(input.cwd);
105
+ if (!cwd.ok)
106
+ throw Object.assign(new Error(cwd.message), { code: cwd.code });
107
+ return readTags(cwd.value, input.count);
108
+ },
109
+ async commit(args) {
110
+ const input = toArgs(args);
111
+ const cwd = validateCwd(input.cwd);
112
+ if (!cwd.ok)
113
+ throw Object.assign(new Error(cwd.message), { code: cwd.code });
114
+ const sha = validateCommitSha(input.sha);
115
+ if (!sha.ok)
116
+ throw Object.assign(new Error(sha.message), { code: sha.code });
117
+ return readCommit(cwd.value, sha.value);
118
+ },
119
+ async 'commit-diff'(args) {
120
+ const input = toArgs(args);
121
+ const cwd = validateCwd(input.cwd);
122
+ if (!cwd.ok)
123
+ throw Object.assign(new Error(cwd.message), { code: cwd.code });
124
+ const sha = validateCommitSha(input.sha);
125
+ if (!sha.ok)
126
+ throw Object.assign(new Error(sha.message), { code: sha.code });
127
+ const file = validateDiffPath(input.path);
128
+ if (!file.ok)
129
+ throw Object.assign(new Error(file.message), { code: file.code });
130
+ let origFile = null;
131
+ if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
132
+ const checked = validateDiffPath(input.origPath, 'origPath');
133
+ if (!checked.ok)
134
+ throw Object.assign(new Error(checked.message), { code: checked.code });
135
+ origFile = checked.value;
136
+ }
137
+ return readCommitDiff(cwd.value, sha.value, file.value, origFile);
138
+ },
139
+ /**
140
+ * 本会话改过哪些文件(按会话归因)。
141
+ *
142
+ * 只读:只回答"这个会话动过哪些路径",与工作区现状的关系由浏览器半自己用 `status` 求交 —
143
+ * 这样端点不需要知道 git 的事,两个事实各自只有一个来源。
144
+ */
145
+ async 'session-changes'(args) {
146
+ const input = toArgs(args);
147
+ const cwd = validateCwd(input.cwd);
148
+ if (!cwd.ok)
149
+ throw Object.assign(new Error(cwd.message), { code: cwd.code });
150
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null;
151
+ return readSessionChanges(cwd.value, sessionId);
152
+ },
153
+ };
154
+ /** 响应信封:`{ type:"server-response", rpcId, result }`。 */
155
+ export function envelope(rpcId, result) {
156
+ return { type: 'server-response', rpcId, result };
157
+ }
158
+ export function failure(rpcId, code, message, details) {
159
+ return envelope(rpcId, { ok: false, error: { code, message, details: details || {} } });
160
+ }
161
+ export function sendJson(res, status, body) {
162
+ const payload = JSON.stringify(body) ?? '';
163
+ res.statusCode = status;
164
+ res.setHeader('content-type', 'application/json; charset=utf-8');
165
+ res.setHeader('content-length', Buffer.byteLength(payload));
166
+ res.end(payload);
167
+ }
168
+ /** 读请求体,超过上限即拒绝(不继续缓冲)。 */
169
+ export function readBody(req, limit) {
170
+ return new Promise((resolve, reject) => {
171
+ let size = 0;
172
+ const chunks = [];
173
+ req.on('data', (chunk) => {
174
+ size += chunk.length;
175
+ if (size > limit) {
176
+ reject(Object.assign(new Error('payload too large'), { code: 'too-large' }));
177
+ return;
178
+ }
179
+ chunks.push(chunk);
180
+ });
181
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
182
+ req.on('error', reject);
183
+ });
184
+ }
185
+ /** 从异常里取诊断码(没有就退回调用方给的兜底值)。 */
186
+ function errorCodeOf(error, fallback) {
187
+ if (error !== null &&
188
+ typeof error === 'object' &&
189
+ 'code' in error &&
190
+ typeof error.code === 'string')
191
+ return error.code;
192
+ return fallback;
193
+ }
194
+ function connectionFenceOf(ctx) {
195
+ const service = ctx.get('connection');
196
+ if (!service || typeof service !== 'object')
197
+ return null;
198
+ if (!('requestRejection' in service) || typeof service.requestRejection !== 'function')
199
+ return null;
200
+ return service;
201
+ }
202
+ function clientRequestOf(value) {
203
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
204
+ return null;
205
+ const record = value;
206
+ if (record.type !== 'client-request')
207
+ return null;
208
+ if (typeof record.rpcId !== 'string' || typeof record.method !== 'string')
209
+ return null;
210
+ if (!('payload' in record))
211
+ return null;
212
+ return { rpcId: record.rpcId, method: record.method, payload: record.payload };
213
+ }
214
+ /** 信封不合法但还能读出一个 rpcId 时,把它回显到错误信封里;否则用固定占位。 */
215
+ function fallbackRpcIdOf(value) {
216
+ if (value !== null &&
217
+ typeof value === 'object' &&
218
+ 'rpcId' in value &&
219
+ typeof value.rpcId === 'string')
220
+ return value.rpcId;
221
+ return 'invalid-request';
222
+ }
223
+ /**
224
+ * 路由处理器:先过 Connection 围栏,再解析信封,最后分发到端点。
225
+ * 非 POST → 405,非 JSON → 415,超限 → 413,通道外路径 → 404。
226
+ */
227
+ export async function handleRequest(ctx, req, res) {
228
+ const connection = connectionFenceOf(ctx);
229
+ if (connection === null) {
230
+ res.statusCode = 503;
231
+ res.end();
232
+ return;
233
+ }
234
+ const rejection = connection.requestRejection(req);
235
+ if (rejection !== undefined) {
236
+ res.statusCode = rejection;
237
+ res.end();
238
+ return;
239
+ }
240
+ if (req.method !== 'POST') {
241
+ res.statusCode = 405;
242
+ res.end();
243
+ return;
244
+ }
245
+ const contentType = String(req.headers['content-type'] || '');
246
+ if (!contentType.includes('application/json')) {
247
+ res.statusCode = 415;
248
+ res.end();
249
+ return;
250
+ }
251
+ const url = new URL(req.url || '/', 'http://localhost');
252
+ const pathname = url.pathname;
253
+ // 不能只靠「前缀路由已经过滤过路径」:路由是挂载契约,处理器仍要自己确认,
254
+ // 否则 `/somewhere-else` 会被 slice 成一段垃圾端点名。
255
+ if (pathname !== CHANNEL && !pathname.startsWith(`${CHANNEL}/`)) {
256
+ res.statusCode = 404;
257
+ res.end();
258
+ return;
259
+ }
260
+ const endpoint = pathname === CHANNEL ? '' : pathname.slice(CHANNEL.length + 1);
261
+ if (!endpoint || endpoint.includes('/')) {
262
+ res.statusCode = 404;
263
+ res.end();
264
+ return;
265
+ }
266
+ let raw;
267
+ try {
268
+ raw = await readBody(req, MAX_BODY_BYTES);
269
+ }
270
+ catch (error) {
271
+ res.statusCode = errorCodeOf(error, '') === 'too-large' ? 413 : 400;
272
+ res.end();
273
+ return;
274
+ }
275
+ let parsed;
276
+ try {
277
+ parsed = JSON.parse(raw);
278
+ }
279
+ catch {
280
+ sendJson(res, 400, failure('invalid-request', 'internal', 'invalid client-request message', {
281
+ code: 'invalid-request',
282
+ }));
283
+ return;
284
+ }
285
+ const message = clientRequestOf(parsed);
286
+ if (message === null) {
287
+ sendJson(res, 400, failure(fallbackRpcIdOf(parsed), 'internal', 'invalid client-request message', {
288
+ code: 'invalid-request',
289
+ }));
290
+ return;
291
+ }
292
+ const rpcId = message.rpcId;
293
+ const handler = Object.prototype.hasOwnProperty.call(ENDPOINTS, endpoint)
294
+ ? ENDPOINTS[endpoint]
295
+ : null;
296
+ if (handler === null) {
297
+ // 未知方法按 JSON-RPC 语义回 200 + 错误信封:非 2xx 会被浏览器侧的传输层
298
+ // 当成连接失败,插件自己的 details.code 就再也到不了界面。
299
+ sendJson(res, 200, failure(rpcId, 'internal', `unknown endpoint: ${endpoint}`, { code: 'unknown-endpoint' }));
300
+ return;
301
+ }
302
+ const payload = message.payload;
303
+ const args = payload !== null && payload !== undefined && typeof payload === 'object'
304
+ ? payload.args
305
+ : undefined;
306
+ try {
307
+ const value = await handler(args);
308
+ sendJson(res, 200, envelope(rpcId, { ok: true, value }));
309
+ }
310
+ catch (error) {
311
+ const code = errorCodeOf(error, 'git-failed');
312
+ const text = error instanceof Error ? error.message : String(error);
313
+ // 线路上的 error.code 固定为 "internal",插件自己的诊断码放在 details.code,
314
+ // 由浏览器半按码做本地化文案。
315
+ sendJson(res, 200, failure(rpcId, 'internal', text, { code }));
316
+ }
317
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 宿主半的**软依赖**:会话服务与三个投影读数器。
3
+ *
4
+ * 为什么单独一个文件:这些是**模块级状态**,而每个挂载实例各有自己的清理函数
5
+ * (`ctx.inject` 的回调返回 `() => { hostSessions = null }`)—— 状态一旦被拆成两份,
6
+ * 就会变成"服务明明在、却读不到",而且不报错。把它们关在这一个文件里,
7
+ * 这个坑从"要靠小心"变成"结构上不可能犯"。
8
+ *
9
+ * 三块内容的语义:
10
+ *
11
+ * - `sessions`(宿主服务):枚举活着的会话,给 `session-time` / `prompt-history` /
12
+ * `session-statuses` 三个端点用。缺了就回"不可用",侧栏那枚 chip 不画,其余照旧。
13
+ * - `sessionProjections` 的三个键(都是投影,**从完整持久日志折叠**,分页与压实都不影响):
14
+ * - `turnBoundary` → 当前回合号(归因、面板、胶囊用它说"第几轮");
15
+ * - `sessionStats` → 会话计时胶囊要的模型/工具墙钟与轮次步数;
16
+ * - `turnOutline` → 每条 turn 的原始 `prompt`,即输入框 `↑` 召回的数据源。
17
+ *
18
+ * 四者全是**软依赖**:任何一个没到齐,对应功能安静降级,绝不把整块拖下水。
19
+ */
20
+ let hostSessions = null;
21
+ let hostContext = null;
22
+ let sessionTurnReader = null;
23
+ let sessionStatsReader = null;
24
+ let sessionOutlineReader = null;
25
+ /** 记下宿主 ctx:注入回调没到齐、或状态被别的实例卸载清掉时,用它按契约兜底问一次。 */
26
+ export function rememberContext(ctx) {
27
+ hostContext = ctx;
28
+ }
29
+ /** 会话服务:先信注入给的那份,再按契约用 `ctx.get("sessions")` 兜底问 ctx。 */
30
+ export function sessionsService() {
31
+ if (hostSessions !== null)
32
+ return hostSessions;
33
+ try {
34
+ const fallback = hostContext !== null && typeof hostContext.get === 'function'
35
+ ? hostContext.get('sessions')
36
+ : undefined;
37
+ if (fallback === undefined || fallback === null)
38
+ return null;
39
+ return fallback;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** 这个会话现在在第几回合(拿不到就是 null,界面据此不显示回合信息)。 */
46
+ export function sessionTurn(session) {
47
+ if (sessionTurnReader === null || session === undefined || session === null)
48
+ return null;
49
+ return sessionTurnReader(session);
50
+ }
51
+ /** 这个会话的累计墙钟与轮次/步数(`sessionStats` 投影);投影没注册就回 null。 */
52
+ export function sessionStats(session) {
53
+ if (sessionStatsReader === null || session === undefined || session === null)
54
+ return null;
55
+ return sessionStatsReader(session);
56
+ }
57
+ /** 这个会话的 turn 大纲(`turnOutline` 投影,含每条 turn 的 prompt);没注册就回 null。 */
58
+ export function sessionOutline(session) {
59
+ if (sessionOutlineReader === null || session === undefined || session === null)
60
+ return null;
61
+ return sessionOutlineReader(session);
62
+ }
63
+ /**
64
+ * 挂上三个软依赖。`stops` 是调用方收集清理函数的数组(卸载时逐个调用)。
65
+ *
66
+ * 语义与原实现完全一致:依赖没到齐就**永远不回调**(Cordis 的动态注入),
67
+ * 到齐后把读数器装好,卸载时再清成 null。
68
+ */
69
+ export function registerSoftDependencies(ctx, stops) {
70
+ if (typeof ctx.inject !== 'function')
71
+ return;
72
+ stops.push(ctx.inject(['sessions'], (scope) => {
73
+ hostSessions = scope.sessions;
74
+ return () => {
75
+ hostSessions = null;
76
+ };
77
+ }));
78
+ stops.push(ctx.inject(['sessionProjections'], (scope) => {
79
+ sessionOutlineReader = (session) => {
80
+ try {
81
+ const state = scope.sessionProjections.stateOf(session, 'turnOutline');
82
+ return state === undefined || state === null ? null : state;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ };
88
+ sessionStatsReader = (session) => {
89
+ try {
90
+ const state = scope.sessionProjections.stateOf(session, 'sessionStats');
91
+ if (state === undefined || state === null)
92
+ return null;
93
+ const stats = state;
94
+ const num = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
95
+ return {
96
+ turns: num(stats.turns),
97
+ steps: num(stats.steps),
98
+ llmMs: num(stats.llmMs),
99
+ toolMs: num(stats.toolMs),
100
+ };
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ };
106
+ sessionTurnReader = (session) => {
107
+ try {
108
+ const state = scope.sessionProjections.stateOf(session, 'turnBoundary');
109
+ const turn = state === undefined || state === null
110
+ ? null
111
+ : state.lastTurn;
112
+ return typeof turn === 'number' && turn >= 0 ? turn : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ };
118
+ return () => {
119
+ sessionTurnReader = null;
120
+ sessionStatsReader = null;
121
+ sessionOutlineReader = null;
122
+ };
123
+ }));
124
+ }