@konnga/dsh-hud 0.9.1 → 0.10.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.
@@ -0,0 +1,1009 @@
1
+ /**
2
+ * 会话改动归因 + 会话级读取:工具活动、改动计数、审阅进度、prompt 历史、运行时长。
3
+ */
4
+ import { readStatusCached } from './readers.js';
5
+ import { registerSoftDependencies, sessionOutline, sessionStats, sessionTurn, sessionsService, } from './services.js';
6
+ import path from 'node:path';
7
+ /** 一次最多看多少个工作区(多个会话可能共用一个目录,按目录去重之后再算)。 */
8
+ export const ROW_STATUS_MAX_WORKTREES = 8;
9
+ /**
10
+ * 一个会话的用户 prompt 历史,**新的在前**(给输入框的 `↑` 召回用)。
11
+ *
12
+ * 来源是内置 `turnOutline` 投影的每条 `prompt` —— 也就是会话日志里真正的用户消息,
13
+ * 不是我们自己记的一份。截断到 `limit` 条,相邻重复(同一句连发两次)只留一条。
14
+ * 投影没注册、会话不在(还没恢复进存储 / 已归档)时回 `available: false` 与空列表。
15
+ *
16
+ * @param sessionId - 会话 id。
17
+ * @param limit - 最多返回多少条(1..200,默认 {@link PROMPT_HISTORY_MAX})。
18
+ */
19
+ export function readPromptHistory(sessionId, limit = PROMPT_HISTORY_MAX) {
20
+ const empty = { available: false, sessionId, prompts: [] };
21
+ if (typeof sessionId !== 'string' || sessionId.length === 0)
22
+ return empty;
23
+ const max = typeof limit === 'number' && Number.isFinite(limit)
24
+ ? Math.min(200, Math.max(1, Math.trunc(limit)))
25
+ : PROMPT_HISTORY_MAX;
26
+ const sessionStore = sessionsService();
27
+ if (sessionStore === null)
28
+ return empty;
29
+ let live = [];
30
+ try {
31
+ live = sessionStore.list();
32
+ }
33
+ catch {
34
+ return empty;
35
+ }
36
+ const session = live.find((item) => item && item.header && item.header.id === sessionId);
37
+ if (session === undefined)
38
+ return empty;
39
+ const outline = sessionOutline(session);
40
+ const turns = outline !== null && Array.isArray(outline.turns) ? outline.turns : null;
41
+ if (turns === null)
42
+ return empty;
43
+ const prompts = [];
44
+ for (let index = turns.length - 1; index >= 0 && prompts.length < max; index -= 1) {
45
+ const turn = turns[index];
46
+ const raw = turn && typeof turn.prompt === 'string' ? turn.prompt.trim() : '';
47
+ if (raw.length === 0)
48
+ continue;
49
+ if (prompts.length > 0 && prompts[prompts.length - 1] === raw)
50
+ continue;
51
+ prompts.push(raw);
52
+ }
53
+ return { available: true, sessionId, prompts };
54
+ }
55
+ /**
56
+ * 一个会话的"运行时长"事实:创建时刻 + 内置 `sessionStats` 的累计数字。
57
+ *
58
+ * 与 codex-hud 的会话时长口径一致:**挂钟时长 = 现在 − 会话创建时刻**(含等待与空闲),
59
+ * 另外把"真的在干活"的两段(模型墙钟 `llmMs`、工具墙钟 `toolMs`)一并给出,让胶囊的悬停
60
+ * 能说清"74 小时里其实只有 1 小时 52 分在跑"。
61
+ *
62
+ * 只读;`sessions` 服务没到齐或会话已不在(归档、子会话已回收)时回 `available: false`,
63
+ * 客户端据此不画这枚胶囊。
64
+ *
65
+ * @param sessionId - 会话 id。
66
+ * @returns `{ available, sessionId, createdAt, turns, steps, llmMs, toolMs, now }`。
67
+ */
68
+ export function readSessionTime(sessionId) {
69
+ const now = Date.now();
70
+ const empty = {
71
+ available: false,
72
+ sessionId,
73
+ createdAt: null,
74
+ turns: null,
75
+ steps: null,
76
+ llmMs: null,
77
+ toolMs: null,
78
+ now,
79
+ };
80
+ if (typeof sessionId !== 'string' || sessionId.length === 0)
81
+ return empty;
82
+ const sessionStore = sessionsService();
83
+ if (sessionStore === null)
84
+ return empty;
85
+ let live = [];
86
+ try {
87
+ live = sessionStore.list();
88
+ }
89
+ catch {
90
+ return empty;
91
+ }
92
+ const session = live.find((item) => item && item.header && item.header.id === sessionId);
93
+ if (session === undefined)
94
+ return empty;
95
+ const header = session.header;
96
+ const createdAt = header !== undefined && header !== null && typeof header.createdAt === 'number'
97
+ ? header.createdAt
98
+ : null;
99
+ const stats = sessionStats(session);
100
+ return {
101
+ available: true,
102
+ sessionId,
103
+ createdAt,
104
+ turns: stats === null ? null : stats.turns,
105
+ steps: stats === null ? null : stats.steps,
106
+ llmMs: stats === null ? null : stats.llmMs,
107
+ toolMs: stats === null ? null : stats.toolMs,
108
+ now,
109
+ };
110
+ }
111
+ /** 从异常里取一个可回报的诊断码(没有就退回 `git-failed`)。 */
112
+ function failureCodeOf(error) {
113
+ if (error !== null &&
114
+ typeof error === 'object' &&
115
+ 'code' in error &&
116
+ typeof error.code === 'string' &&
117
+ error.code.length > 0)
118
+ return error.code;
119
+ return 'git-failed';
120
+ }
121
+ /**
122
+ * 会话一览:每个活着的会话 → (它那个工作区的 git 摘要)+(这个会话自己的改动计数)。
123
+ *
124
+ * 两半各有一个唯一来源:git 那半走带缓存的 `readStatusCached`(按目录去重,多会话共用
125
+ * 一个目录只跑一次 git),"这个会话改过什么"那半直接来自归因记录(本来就按 sessionId 分,
126
+ * 不用再问 git)。**不返回 `changes`**:这一行只要摘要,别把清单也拖过来。
127
+ */
128
+ export async function readSessionOverview() {
129
+ const sessionStore = sessionsService();
130
+ if (sessionStore === null)
131
+ return { sessions: [], unavailable: true, truncated: false, worktrees: 0 };
132
+ let live = [];
133
+ try {
134
+ live = sessionStore.list();
135
+ }
136
+ catch {
137
+ return { sessions: [], unavailable: true, truncated: false, worktrees: 0 };
138
+ }
139
+ // 会话 → 目录;同一个目录只读一次。
140
+ const byCwd = new Map();
141
+ const rows = [];
142
+ for (const session of live) {
143
+ const header = session && session.header ? session.header : undefined;
144
+ const sessionId = header && typeof header.id === 'string' ? header.id : null;
145
+ if (sessionId === null)
146
+ continue;
147
+ const cwd = header && typeof header.cwd === 'string' && header.cwd.length > 0 ? header.cwd : null;
148
+ rows.push({ sessionId, cwd });
149
+ if (cwd !== null && !byCwd.has(cwd))
150
+ byCwd.set(cwd, null);
151
+ }
152
+ const cwds = [...byCwd.keys()];
153
+ const used = cwds.slice(0, ROW_STATUS_MAX_WORKTREES);
154
+ const truncated = cwds.length > used.length;
155
+ await Promise.all(used.map(async (cwd) => {
156
+ try {
157
+ byCwd.set(cwd, await readStatusCached(cwd));
158
+ }
159
+ catch (error) {
160
+ byCwd.set(cwd, { error: failureCodeOf(error) });
161
+ }
162
+ }));
163
+ const sessions = rows.map((row) => {
164
+ // 会话自己的改动计数(确定 / 可能),来自归因记录 —— 与 git 无关,非仓库也能报。
165
+ const entry = sessionChanges.get(row.sessionId);
166
+ const summary = {
167
+ sessionId: row.sessionId,
168
+ cwd: row.cwd,
169
+ mine: entry === undefined ? 0 : entry.files.size,
170
+ possible: entry === undefined ? 0 : entry.possible.size,
171
+ };
172
+ /**
173
+ * 「可能相关」里**此刻仍然有差异**的个数(与这份 status 的清单求交)。
174
+ *
175
+ * 为什么要两个数:`possible` 是"命令里提到过的路径"(历史,含已经还原/被忽略的),
176
+ * 面板那一行只数"现在还有差异"的。同一个概念两个口径,界面上必须分别说清,
177
+ * 否则就会出现"工作区干净 + 14 个可能相关"这种让人以为算错了的组合。
178
+ */
179
+ const possibleNow = entry === undefined
180
+ ? 0
181
+ : (() => {
182
+ const status = row.cwd === null ? undefined : byCwd.get(row.cwd);
183
+ if (status === undefined ||
184
+ status === null ||
185
+ !('changes' in status) ||
186
+ Array.isArray(status.changes) !== true)
187
+ return 0;
188
+ let count = 0;
189
+ for (const change of status.changes)
190
+ if (entry.possible.has(change.file))
191
+ count += 1;
192
+ return count;
193
+ })();
194
+ if (row.cwd === null)
195
+ return { ...summary, possibleNow: 0, repo: false, error: null };
196
+ const status = byCwd.get(row.cwd);
197
+ if (status === undefined || status === null)
198
+ return { ...summary, possibleNow: 0, repo: false, error: null, pending: true };
199
+ if ('error' in status)
200
+ return { ...summary, possibleNow: 0, repo: false, error: status.error };
201
+ return {
202
+ ...summary,
203
+ possibleNow,
204
+ repo: status.repo !== null,
205
+ branch: status.branch,
206
+ detached: status.detached === true,
207
+ dirty: status.dirty === true,
208
+ ahead: status.ahead || 0,
209
+ behind: status.behind || 0,
210
+ counts: status.counts,
211
+ error: null,
212
+ };
213
+ });
214
+ return { sessions, unavailable: false, truncated, worktrees: used.length };
215
+ }
216
+ /** 单个会话最多记多少个文件;超出只计数(`dropped`),不做 LRU 淘汰。 */
217
+ export const SESSION_CHANGE_MAX_FILES = 500;
218
+ /** 最多同时记多少个会话(按最近一次改动淘汰最旧的)。 */
219
+ export const SESSION_CHANGE_MAX_SESSIONS = 32;
220
+ /** 单个会话最多记多少个"可能相关"的路径(命令旁听来的,优先级低于确定改动)。 */
221
+ export const SESSION_CHANGE_MAX_POSSIBLE = 300;
222
+ /** 每个会话留多少条命令历史(面板悬停时能看到"跑过什么")。 */
223
+ export const SESSION_COMMAND_MAX = 20;
224
+ /** 命令在界面上的显示上限(超长命令截断,别把整行撑爆)。 */
225
+ export const COMMAND_TEXT_MAX = 140;
226
+ /** 每个会话留多少条"最近工具调用"(胶囊悬停里列最近几条)。 */
227
+ export const TOOL_RECENT_MAX = 12;
228
+ /** 最多同时记多少个会话的工具活动(与归因那份各自独立淘汰)。 */
229
+ export const TOOL_SESSIONS_MAX = 32;
230
+ /** 每个会话最多记多少个技能(超出的只计数不列名)。 */
231
+ export const TOOL_SKILLS_MAX = 20;
232
+ /** 参数摘要的显示上限(超长命令/路径截断,别把胶囊撑爆)。 */
233
+ export const TOOL_SUMMARY_MAX = 60;
234
+ /**
235
+ * sessionId → { active, recent, counts, total }:这个会话**跑到哪些工具**。
236
+ *
237
+ * 与归因那份记录分开存:归因关心"改了哪些文件",这份关心"跑了什么"。
238
+ */
239
+ export const sessionTools = new Map();
240
+ /** 取(必要时新建)某个会话的工具活动记录,并做数量上限淘汰。 */
241
+ export function sessionToolEntry(sessionId) {
242
+ let entry = sessionTools.get(sessionId);
243
+ if (entry === undefined) {
244
+ entry = {
245
+ active: null,
246
+ recent: [],
247
+ counts: new Map(),
248
+ status: new Map(),
249
+ skills: new Map(),
250
+ total: 0,
251
+ };
252
+ sessionTools.set(sessionId, entry);
253
+ if (sessionTools.size > TOOL_SESSIONS_MAX) {
254
+ const oldest = sessionTools.keys().next().value;
255
+ if (oldest !== undefined && oldest !== sessionId)
256
+ sessionTools.delete(oldest);
257
+ }
258
+ }
259
+ return entry;
260
+ }
261
+ /**
262
+ * 参数里可以安全拿出来做"一行摘要"的键。
263
+ *
264
+ * 只认这些**路径/命令类**的键:工具参数里可能带着整个文件内容(write 的 `content`),
265
+ * 随便取"第一个字符串"就会把内容贴到界面上 —— 所以认不出来就干脆不写摘要。
266
+ */
267
+ export const TOOL_SUMMARY_KEYS = [
268
+ 'command',
269
+ 'cmd',
270
+ 'script',
271
+ 'filePath',
272
+ 'path',
273
+ 'file',
274
+ 'pattern',
275
+ 'query',
276
+ 'url',
277
+ ];
278
+ /** 「调用技能」那件工具的工具名(`dsh-tool-skill` 注册的名字)。 */
279
+ export const SKILL_TOOL_NAME = 'skill';
280
+ /**
281
+ * 技能名:`skill` 工具的入参是 `{ name: '技能名' }`。
282
+ *
283
+ * 只对这一个工具认 `name` 键 —— 别的工具用 `name` 表达别的东西,不能被当技能名抓走。
284
+ */
285
+ export function skillNameOf(exec) {
286
+ if (exec === null || typeof exec !== 'object' || exec.name !== SKILL_TOOL_NAME)
287
+ return null;
288
+ const args = exec.arguments;
289
+ if (args === null || typeof args !== 'object')
290
+ return null;
291
+ const value = args.name;
292
+ return typeof value === 'string' && value.trim().length > 0
293
+ ? value.trim().slice(0, TOOL_SUMMARY_MAX)
294
+ : null;
295
+ }
296
+ /** 工具名:从执行上下文里取,取不到就退回 `'?'`。 */
297
+ function execNameOf(exec) {
298
+ return exec !== null && exec !== undefined && typeof exec.name === 'string' ? exec.name : '?';
299
+ }
300
+ /** 从工具调用参数里取一行摘要(认不出来返回 null,不用兜底去猜)。 */
301
+ export function toolSummaryOf(exec) {
302
+ const skill = skillNameOf(exec);
303
+ if (skill !== null)
304
+ return skill;
305
+ const args = exec && exec.arguments;
306
+ if (args === null || args === undefined || typeof args !== 'object')
307
+ return null;
308
+ const record = args;
309
+ for (const key of TOOL_SUMMARY_KEYS) {
310
+ const value = record[key];
311
+ if (typeof value === 'string' && value.trim().length > 0) {
312
+ return value.replace(/\s+/g, ' ').trim().slice(0, TOOL_SUMMARY_MAX);
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+ /** 工具开跑:记下"现在是哪个工具、什么时候开始的"(供胶囊显示实时秒数)。 */
318
+ export function noteToolStart(exec) {
319
+ try {
320
+ const identity = sessionIdentityOf(exec);
321
+ if (identity === null)
322
+ return;
323
+ const entry = sessionToolEntry(identity.sessionId);
324
+ const rawName = execNameOf(exec);
325
+ const name = rawName.length > 0 ? rawName : '?';
326
+ entry.active = {
327
+ callId: identity.callId,
328
+ name,
329
+ summary: toolSummaryOf(exec),
330
+ startedAt: Date.now(),
331
+ };
332
+ entry.total += 1;
333
+ entry.counts.set(name, (entry.counts.get(name) || 0) + 1);
334
+ const skill = skillNameOf(exec);
335
+ if (skill !== null) {
336
+ const seen = entry.skills.get(skill);
337
+ entry.skills.set(skill, { count: (seen ? seen.count : 0) + 1, ok: seen ? seen.ok : null });
338
+ }
339
+ }
340
+ catch {
341
+ // 旁听失败不影响工具本身。
342
+ }
343
+ }
344
+ /** 工具结束:把这一次挪进"最近",并清掉 active(只有同一个 callId 才清,避免并发调用互相抹掉)。 */
345
+ export function noteToolFinish(exec, result) {
346
+ try {
347
+ const identity = sessionIdentityOf(exec);
348
+ if (identity === null)
349
+ return;
350
+ const entry = sessionToolEntry(identity.sessionId);
351
+ const active = entry.active;
352
+ const startedAt = active !== null && active.callId === identity.callId ? active.startedAt : null;
353
+ entry.recent.push({
354
+ name: active !== null && active.callId === identity.callId ? active.name : execNameOf(exec),
355
+ summary: active !== null && active.callId === identity.callId ? active.summary : toolSummaryOf(exec),
356
+ ok: !resultFailed(result),
357
+ at: Date.now(),
358
+ ms: startedAt === null ? null : Math.max(0, Date.now() - startedAt),
359
+ });
360
+ if (entry.recent.length > TOOL_RECENT_MAX)
361
+ entry.recent.splice(0, entry.recent.length - TOOL_RECENT_MAX);
362
+ // 每个工具"最近一次调用"的结果:胶囊上每个工具前面那个 ✓/✗ 用它。
363
+ const finished = entry.recent[entry.recent.length - 1];
364
+ if (finished !== undefined)
365
+ entry.status.set(finished.name, finished.ok === true);
366
+ // 技能自己的最近一次结果(胶囊上可以据此打 ✓/✗)。
367
+ const skill = skillNameOf(exec);
368
+ if (skill !== null) {
369
+ const known = entry.skills.get(skill);
370
+ if (known !== undefined)
371
+ entry.skills.set(skill, { count: known.count, ok: !resultFailed(result) });
372
+ }
373
+ if (active !== null && active.callId === identity.callId)
374
+ entry.active = null;
375
+ }
376
+ catch {
377
+ // 同上。
378
+ }
379
+ }
380
+ /**
381
+ * 读出某个会话的工具活动(端点载荷)。
382
+ *
383
+ * `active` 带 `startedAt`:实时秒数由客户端本地计时(否则要为了一个秒数把轮询压到亚秒级 ✗)。
384
+ */
385
+ export function readSessionTools(sessionId) {
386
+ const entry = sessionId === null || sessionId === undefined ? undefined : sessionTools.get(sessionId);
387
+ if (entry === undefined) {
388
+ return {
389
+ sessionId: sessionId ?? null,
390
+ tracking: true,
391
+ active: null,
392
+ recent: [],
393
+ counts: [],
394
+ skills: [],
395
+ skillsDropped: 0,
396
+ total: 0,
397
+ };
398
+ }
399
+ const counts = [...entry.counts].map(([name, count]) => ({
400
+ name,
401
+ count,
402
+ // `null` = 还没看到它结束过(比如正在跑第一次),别替它编一个 ✓。
403
+ ok: entry.status.has(name) ? (entry.status.get(name) ?? null) : null,
404
+ }));
405
+ counts.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1));
406
+ // 技能按次数排序(用得多在前),超过上限只报前若干个。
407
+ const skills = [...entry.skills].map(([name, item]) => ({
408
+ name,
409
+ count: item.count,
410
+ ok: item.ok,
411
+ }));
412
+ skills.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1));
413
+ return {
414
+ sessionId,
415
+ tracking: true,
416
+ active: entry.active === null ? null : { ...entry.active },
417
+ recent: entry.recent.slice(-5).reverse(),
418
+ counts,
419
+ skills: skills.slice(0, TOOL_SKILLS_MAX),
420
+ skillsDropped: Math.max(0, skills.length - TOOL_SKILLS_MAX),
421
+ total: entry.total,
422
+ };
423
+ }
424
+ /** sessionId → { cwd, since, files: Map<relPath, {...}>, dropped, unconfirmed }。 */
425
+ export const sessionChanges = new Map();
426
+ /** 本次监听开始的时刻 —— 界面说的"自 … 起记录"用的是它(不是"第一条改动的时间")。 */
427
+ export let sessionChangeTrackingSince = null;
428
+ /** callId(没有就退化成 sessionId+路径)→ 尚未配对到 `fs/observed` 的意图。 */
429
+ export const pendingChangeIntents = new Map();
430
+ /** prompt 历史的条数上限(与客户端的 `↑` 召回共用这一个数)。 */
431
+ export const PROMPT_HISTORY_MAX = 100;
432
+ /** 这次调用发生在第几回合(拿不到就是 null,界面据此不显示回合信息)。 */
433
+ export function currentTurnOf(exec) {
434
+ const session = exec && exec.agent ? exec.agent.session : undefined;
435
+ return sessionTurn(session);
436
+ }
437
+ /** 这个会话第一次出现在记录里的时刻(客户端据此说"自 … 起")。 */
438
+ export function sessionChangeEntry(sessionId, cwd) {
439
+ let entry = sessionChanges.get(sessionId);
440
+ if (entry === undefined) {
441
+ entry = {
442
+ cwd,
443
+ since: Date.now(),
444
+ files: new Map(),
445
+ dropped: 0,
446
+ // 命令旁听来的"可能相关":与 files 分开存,绝不冒充"确定改过"。
447
+ possible: new Map(),
448
+ possibleDropped: 0,
449
+ commands: [],
450
+ };
451
+ sessionChanges.set(sessionId, entry);
452
+ if (sessionChanges.size > SESSION_CHANGE_MAX_SESSIONS) {
453
+ // 先淘汰最久没动过的那个(Map 保持插入序,删第一个就是最旧的)。
454
+ const oldest = sessionChanges.keys().next().value;
455
+ if (oldest !== undefined && oldest !== sessionId)
456
+ sessionChanges.delete(oldest);
457
+ }
458
+ }
459
+ if (cwd !== undefined && cwd !== null)
460
+ entry.cwd = cwd;
461
+ return entry;
462
+ }
463
+ /** 把绝对路径折成 cwd 之内的 posix 相对路径;在 cwd 之外或拿不到都返回 null。 */
464
+ export function sessionRelativePath(cwd, absolutePath) {
465
+ if (typeof cwd !== 'string' || cwd.length === 0)
466
+ return null;
467
+ if (typeof absolutePath !== 'string' || absolutePath.length === 0)
468
+ return null;
469
+ if (!path.isAbsolute(absolutePath))
470
+ return null;
471
+ const relative = path.relative(cwd, absolutePath);
472
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative))
473
+ return null;
474
+ return relative.split(path.sep).join('/');
475
+ }
476
+ /**
477
+ * 会自己改文件的"命令行工具":bash / pwsh / 各种 shell。
478
+ *
479
+ * 用名字前缀匹配而不是精确清单:产品里有 `bash`、`bash-persistent`、`pwsh`、`pwsh-persistent`
480
+ * 等等,将来还会加,硬编码清单一定会过期。
481
+ */
482
+ export const SHELL_TOOL_RE = /^(?:bash|pwsh|sh|zsh|fish|shell|cmd|powershell|exec_command|run_command|terminal)/i;
483
+ /** 从工具调用参数里取命令行文本(不同 shell 工具的字段名不一样,按常见几个找)。 */
484
+ export function shellCommandOf(exec) {
485
+ if (exec === undefined || exec === null)
486
+ return null;
487
+ if (typeof exec.name !== 'string' || !SHELL_TOOL_RE.test(exec.name))
488
+ return null;
489
+ const args = exec.arguments;
490
+ if (args === null || typeof args !== 'object')
491
+ return null;
492
+ const record = args;
493
+ for (const key of ['command', 'cmd', 'script', 'input', 'line']) {
494
+ const value = record[key];
495
+ if (typeof value === 'string' && value.trim().length > 0)
496
+ return value;
497
+ }
498
+ return null;
499
+ }
500
+ /** 按 `; && || | 换行` 切分命令(只为抽路径,不做真正的 shell 解析)。 */
501
+ export function shellSegments(command) {
502
+ return command
503
+ .split(/\r?\n|;|&&|\|\||\|/)
504
+ .map((segment) => segment.trim())
505
+ .filter((segment) => segment.length > 0);
506
+ }
507
+ /** 极简分词:支持单双引号(不处理转义与嵌套),够抽路径用。 */
508
+ export function shellTokens(segment) {
509
+ const tokens = [];
510
+ let current = '';
511
+ let quote = null;
512
+ for (const ch of segment) {
513
+ if (quote !== null) {
514
+ if (ch === quote)
515
+ quote = null;
516
+ else
517
+ current += ch;
518
+ continue;
519
+ }
520
+ if (ch === '"' || ch === "'") {
521
+ quote = ch;
522
+ continue;
523
+ }
524
+ if (ch === ' ' || ch === '\t') {
525
+ if (current.length > 0) {
526
+ tokens.push(current);
527
+ current = '';
528
+ }
529
+ continue;
530
+ }
531
+ current += ch;
532
+ }
533
+ if (current.length > 0)
534
+ tokens.push(current);
535
+ return tokens;
536
+ }
537
+ /** 环境变量赋值、sudo/env 之类的包装前缀:抽路径时要跳过。 */
538
+ export const SHELL_PREFIX_RE = /^(?:[A-Za-z_][A-Za-z0-9_]*=|sudo$|env$|time$|nohup$|command$|builtin$|exec$|then$|do$|if$|while$)/;
539
+ /** 一条命令里最多抽多少个路径(防御性上限)。 */
540
+ const COMMAND_PATHS_MAX = 20;
541
+ /** 写入类命令:它们改文件,"目标"在哪一个参数上按命令而定。 */
542
+ const WRITE_COMMANDS = new Set([
543
+ 'mv',
544
+ 'cp',
545
+ 'ln',
546
+ 'install',
547
+ 'rsync',
548
+ 'truncate',
549
+ 'touch',
550
+ 'rm',
551
+ 'unlink',
552
+ 'sed',
553
+ 'perl',
554
+ 'tee',
555
+ 'dd',
556
+ 'patch',
557
+ 'chmod',
558
+ 'chown',
559
+ 'gzip',
560
+ 'gunzip',
561
+ 'tar',
562
+ 'unzip',
563
+ 'zip',
564
+ ]);
565
+ /** 只读命令:即使带了参数也不要记成"可能改过"。 */
566
+ export const READ_COMMANDS = new Set([
567
+ 'cat',
568
+ 'less',
569
+ 'more',
570
+ 'head',
571
+ 'tail',
572
+ 'grep',
573
+ 'rg',
574
+ 'ls',
575
+ 'find',
576
+ 'stat',
577
+ 'wc',
578
+ 'file',
579
+ 'du',
580
+ 'diff',
581
+ 'git',
582
+ 'echo',
583
+ 'printf',
584
+ 'pwd',
585
+ 'which',
586
+ 'type',
587
+ 'env',
588
+ 'date',
589
+ 'sort',
590
+ 'uniq',
591
+ 'awk',
592
+ 'jq',
593
+ 'node',
594
+ 'python',
595
+ 'python3',
596
+ 'true',
597
+ 'false',
598
+ 'test',
599
+ 'cd',
600
+ 'export',
601
+ 'set',
602
+ 'source',
603
+ ]);
604
+ /**
605
+ * 从一条命令里抽出"可能被改动的路径"。
606
+ *
607
+ * 这是**启发式**,只做三类有把握的事,其余一律退回"命令级提示"(不声称改过哪些路径):
608
+ * 1. 重定向与 `tee` 的目标(`>`、`>>`、`tee file`);
609
+ * 2. 原地编辑(`sed -i`、`perl -i`)与写入类命令(`mv`/`cp`/`rm`/`dd of=`…)的目标参数;
610
+ * 3. `git checkout|restore <path>` 这类"把文件改回某个版本"的命令。
611
+ * 只读命令(`cat`、`grep`、`git status`…)即使带路径也不记。
612
+ * @returns {{ path: string, reason: string }[]} 去重后的候选路径(原样 token,未解析)。
613
+ */
614
+ export function possiblePathsOf(command) {
615
+ const found = new Map();
616
+ const add = (token, reason) => {
617
+ const cleaned = String(token).replace(/^["']|["']$/g, '');
618
+ if (cleaned.length === 0 || cleaned === '/' || cleaned.startsWith('/dev/'))
619
+ return;
620
+ if (/^&?\d+$/.test(cleaned) || cleaned.startsWith('&'))
621
+ return;
622
+ if (cleaned.startsWith('-'))
623
+ return;
624
+ if (cleaned.includes('$') || cleaned.includes('*') || cleaned.includes('?'))
625
+ return;
626
+ if (found.has(cleaned))
627
+ return;
628
+ found.set(cleaned, reason.slice(0, 80));
629
+ };
630
+ for (const segment of shellSegments(command)) {
631
+ // 1) 重定向与 tee:即使命令本身不认识也要抽。
632
+ const redirects = segment.matchAll(/(?:^|[^0-9<>])>>?\s*([^\s;&|<>()]+)/g);
633
+ for (const match of redirects)
634
+ add(match[1], segment);
635
+ const tees = segment.matchAll(/\btee\s+(?:-[A-Za-z-]+\s+)*([^\s;&|<>()]+)/g);
636
+ for (const match of tees)
637
+ add(match[1], segment);
638
+ const tokens = shellTokens(segment).filter((token) => !SHELL_PREFIX_RE.test(token));
639
+ let index = 0;
640
+ while (index < tokens.length && SHELL_PREFIX_RE.test(tokens[index]))
641
+ index += 1;
642
+ const head = (tokens[index] || '').toLowerCase();
643
+ const args = tokens.slice(index + 1);
644
+ if (head.length === 0)
645
+ continue;
646
+ // 2) 原地编辑:`sed -i … <file>` / `perl -i … <file>` —— 取该段最后一个非 flag 参数。
647
+ if ((head === 'sed' || head === 'perl') && args.some((arg) => /^-[A-Za-z]*i/.test(arg))) {
648
+ const last = [...args].reverse().find((arg) => !arg.startsWith('-'));
649
+ if (last !== undefined)
650
+ add(last, segment);
651
+ continue;
652
+ }
653
+ // 3) `dd of=…`
654
+ if (head === 'dd') {
655
+ for (const arg of args)
656
+ if (arg.startsWith('of='))
657
+ add(arg.slice(3), segment);
658
+ continue;
659
+ }
660
+ // 4) `git checkout|restore <path>`(会覆盖工作区文件)
661
+ if (head === 'git') {
662
+ const sub = (args[0] || '').toLowerCase();
663
+ if (sub === 'checkout' || sub === 'restore') {
664
+ const target = args.slice(1).find((arg) => !arg.startsWith('-'));
665
+ if (target !== undefined && target !== '.')
666
+ add(target, segment);
667
+ }
668
+ continue;
669
+ }
670
+ // 5) 写入类命令:`mv/cp/ln/install/rsync` 记目标(最后一个参数);其余记所有非 flag 参数。
671
+ if (WRITE_COMMANDS.has(head)) {
672
+ if (/^(?:chmod|chown|tar|gzip|gunzip|zip)$/.test(head))
673
+ continue;
674
+ // `mv` 的源也会"变"(消失),所以源与目标都记;其余写入类命令只记目标。
675
+ if (head === 'mv') {
676
+ for (const arg of args)
677
+ if (!arg.startsWith('-'))
678
+ add(arg, segment);
679
+ continue;
680
+ }
681
+ if (/^(?:cp|ln|install|rsync|patch)$/.test(head)) {
682
+ const target = [...args].reverse().find((arg) => !arg.startsWith('-'));
683
+ if (target !== undefined)
684
+ add(target, segment);
685
+ continue;
686
+ }
687
+ for (const arg of args)
688
+ if (!arg.startsWith('-'))
689
+ add(arg, segment);
690
+ continue;
691
+ }
692
+ // 其余(含只读命令、以及 npm/pnpm/prettier 这类"可能改了但说不清改哪"的)一律退回命令级提示。
693
+ void READ_COMMANDS;
694
+ }
695
+ return [...found]
696
+ .slice(0, COMMAND_PATHS_MAX)
697
+ .map(([pathToken, reason]) => ({ path: pathToken, reason }));
698
+ }
699
+ /** 命令是不是"看起来只读"(用于命令历史里的标注)。 */
700
+ export function commandLooksReadOnly(command) {
701
+ const heads = shellSegments(command).map((segment) => shellTokens(segment).filter((token) => !SHELL_PREFIX_RE.test(token))[0] || '');
702
+ if (heads.length === 0)
703
+ return false;
704
+ return heads.every((head) => READ_COMMANDS.has(head.toLowerCase()));
705
+ }
706
+ /** 结果看起来是失败吗(失败的命令不改文件,就不算"可能相关")。 */
707
+ export function resultFailed(result) {
708
+ if (result === null || typeof result !== 'object')
709
+ return false;
710
+ if (result.ok === false)
711
+ return true;
712
+ if (result.isError === true)
713
+ return true;
714
+ return result.error !== undefined && result.error !== null;
715
+ }
716
+ /**
717
+ * 把"第几回合"记到一条记录上(文件 / 可能相关)。
718
+ *
719
+ * `lastTurn` 是最近一次动它的回合,`turns` 是动过它的回合列表(去重、最多 8 个)——
720
+ * 界面用前者做"只看最近一轮",后者留给悬停说明"这个文件被两轮动过"。
721
+ */
722
+ export function noteTurnOfRecord(record, turn) {
723
+ record.lastTurn = turn;
724
+ if (turn === null)
725
+ return;
726
+ if (!Array.isArray(record.turns))
727
+ record.turns = [];
728
+ if (record.turns[record.turns.length - 1] !== turn && record.turns.indexOf(turn) === -1)
729
+ record.turns.push(turn);
730
+ if (record.turns.length > 8)
731
+ record.turns.splice(0, record.turns.length - 8);
732
+ }
733
+ /** 从工具执行上下文里取会话身份与工作区;非 agent 调用返回 null。 */
734
+ export function sessionIdentityOf(exec) {
735
+ const session = exec && exec.agent ? exec.agent.session : undefined;
736
+ const header = session ? session.header : undefined;
737
+ if (header === undefined || header === null)
738
+ return null;
739
+ if (typeof header.id !== 'string' || header.id.length === 0)
740
+ return null;
741
+ const cwd = typeof header.cwd === 'string' && header.cwd.length > 0 ? header.cwd : undefined;
742
+ return {
743
+ sessionId: header.id,
744
+ cwd,
745
+ callId: exec && typeof exec.callId === 'string' ? exec.callId : null,
746
+ };
747
+ }
748
+ /** 意图的配对键:优先用调用 id(一次调用一个意图),没有就退化成"会话 + 路径"。 */
749
+ export function pendingKeyOf(identity, relative) {
750
+ return identity.callId !== null
751
+ ? `call:${identity.callId}`
752
+ : `path:${identity.sessionId}\u0000${relative}`;
753
+ }
754
+ /**
755
+ * 记一次写入/编辑意图(事件回调;绝不抛,也绝不返回 next —— 这是 waterfall 的旁听)。
756
+ * @param target - fs 目标(`displayPath` 是绝对路径)。
757
+ * @param exec - 工具执行上下文。
758
+ * @param kind - `"write"` 或 `"edit"`。
759
+ */
760
+ export function noteChangeIntent(target, exec, kind) {
761
+ try {
762
+ const identity = sessionIdentityOf(exec);
763
+ if (identity === null)
764
+ return;
765
+ const absolute = target && typeof target.displayPath === 'string' ? target.displayPath : null;
766
+ const relative = sessionRelativePath(identity.cwd, absolute);
767
+ if (relative === null)
768
+ return;
769
+ pendingChangeIntents.set(pendingKeyOf(identity, relative), {
770
+ sessionId: identity.sessionId,
771
+ cwd: identity.cwd,
772
+ relative,
773
+ kind,
774
+ at: Date.now(),
775
+ });
776
+ // 配对表也会长:清掉明显过期的(超过 5 分钟还没等到观察的意图)。
777
+ if (pendingChangeIntents.size > 512) {
778
+ const cutoff = Date.now() - 5 * 60 * 1000;
779
+ for (const [key, entry] of pendingChangeIntents)
780
+ if (entry.at < cutoff)
781
+ pendingChangeIntents.delete(key);
782
+ }
783
+ }
784
+ catch {
785
+ // 旁听失败不影响写入本身。
786
+ }
787
+ }
788
+ /**
789
+ * 记一次观察:只有配得上"意图"的才算这次改动落了地(读取引起的观察一律忽略)。
790
+ * @param target - fs 目标。
791
+ * @param exec - 工具执行上下文。
792
+ */
793
+ export function noteChangeObservation(target, exec) {
794
+ try {
795
+ const identity = sessionIdentityOf(exec);
796
+ if (identity === null)
797
+ return;
798
+ const absolute = target && typeof target.displayPath === 'string' ? target.displayPath : null;
799
+ const relative = sessionRelativePath(identity.cwd, absolute);
800
+ if (relative === null)
801
+ return;
802
+ const key = pendingKeyOf(identity, relative);
803
+ const intent = pendingChangeIntents.get(key);
804
+ if (intent === undefined)
805
+ return;
806
+ pendingChangeIntents.delete(key);
807
+ const entry = sessionChangeEntry(intent.sessionId, intent.cwd);
808
+ let file = entry.files.get(intent.relative);
809
+ if (file === undefined) {
810
+ if (entry.files.size >= SESSION_CHANGE_MAX_FILES) {
811
+ entry.dropped += 1;
812
+ return;
813
+ }
814
+ file = {
815
+ writes: 0,
816
+ edits: 0,
817
+ firstAt: intent.at,
818
+ lastAt: intent.at,
819
+ lastTurn: null,
820
+ turns: [],
821
+ };
822
+ entry.files.set(intent.relative, file);
823
+ }
824
+ if (intent.kind === 'write')
825
+ file.writes += 1;
826
+ else
827
+ file.edits += 1;
828
+ file.lastAt = Date.now();
829
+ noteTurnOfRecord(file, currentTurnOf(exec));
830
+ }
831
+ catch {
832
+ // 同上:旁听不参与写入成败。
833
+ }
834
+ }
835
+ /**
836
+ * 记一次 shell 命令(事件回调;旁听,绝不抛,也绝不吞掉 waterfall 的返回值)。
837
+ *
838
+ * 两类信息分开记:
839
+ * - `commands`:跑过什么(含看似只读的、以及失败的),供界面说"跑过这些命令";
840
+ * - `possible`:从命令里抽出来的**候选路径**,只进"可能相关"这一档,绝不写进 `files`。
841
+ *
842
+ * 失败的命令不改文件:只进 `commands`,不参与路径归因。
843
+ */
844
+ export function noteShellCommand(exec, result) {
845
+ try {
846
+ const command = shellCommandOf(exec);
847
+ if (command === null)
848
+ return;
849
+ const identity = sessionIdentityOf(exec);
850
+ if (identity === null)
851
+ return;
852
+ const cwd = typeof identity.cwd === 'string' && identity.cwd.length > 0 ? identity.cwd : null;
853
+ const entry = sessionChangeEntry(identity.sessionId, identity.cwd);
854
+ const failed = resultFailed(result);
855
+ const turn = currentTurnOf(exec);
856
+ entry.commands.push({
857
+ tool: execNameOf(exec),
858
+ command: command.replace(/\s+/g, ' ').trim().slice(0, COMMAND_TEXT_MAX),
859
+ at: Date.now(),
860
+ turn,
861
+ failed,
862
+ readOnly: commandLooksReadOnly(command),
863
+ });
864
+ if (entry.commands.length > SESSION_COMMAND_MAX) {
865
+ entry.commands.splice(0, entry.commands.length - SESSION_COMMAND_MAX);
866
+ }
867
+ if (failed || cwd === null)
868
+ return;
869
+ for (const candidate of possiblePathsOf(command)) {
870
+ const absolute = path.isAbsolute(candidate.path)
871
+ ? candidate.path
872
+ : path.resolve(cwd, candidate.path);
873
+ const relative = sessionRelativePath(cwd, absolute);
874
+ if (relative === null)
875
+ continue;
876
+ let item = entry.possible.get(relative);
877
+ if (item === undefined) {
878
+ if (entry.possible.size >= SESSION_CHANGE_MAX_POSSIBLE) {
879
+ entry.possibleDropped += 1;
880
+ continue;
881
+ }
882
+ item = {
883
+ hits: 0,
884
+ firstAt: Date.now(),
885
+ lastAt: Date.now(),
886
+ reason: candidate.reason,
887
+ lastTurn: turn,
888
+ turns: [],
889
+ };
890
+ entry.possible.set(relative, item);
891
+ }
892
+ item.hits += 1;
893
+ item.lastAt = Date.now();
894
+ noteTurnOfRecord(item, turn);
895
+ // 最近一次提到它的命令片段更有参考价值。
896
+ item.reason = candidate.reason;
897
+ }
898
+ }
899
+ catch {
900
+ // 旁听失败不影响命令本身。
901
+ }
902
+ }
903
+ /**
904
+ * 把某个会话的改动读成端点的载荷(只含 cwd 之内的路径,按路径排序)。
905
+ *
906
+ * 两条诚实性检查:
907
+ * - 会话记录里的 cwd 与请求的 cwd 不一致时返回**空清单**(`cwdMismatch`):记的是相对
908
+ * 那个工作区的路径,跟另一个工作区对账只会标错行。
909
+ * - `unconfirmed` 只数**这个会话**的待配对意图(不是全局),免得把别的会话的在途写入报成它的。
910
+ */
911
+ export function readSessionChanges(cwd, sessionId) {
912
+ const entry = sessionId === null || sessionId === undefined ? undefined : sessionChanges.get(sessionId);
913
+ const mismatched = entry !== undefined && entry.cwd !== undefined && entry.cwd !== null && entry.cwd !== cwd;
914
+ const files = [];
915
+ if (entry !== undefined && !mismatched) {
916
+ for (const [relative, file] of entry.files) {
917
+ files.push({
918
+ path: relative,
919
+ file: relative,
920
+ writes: file.writes,
921
+ edits: file.edits,
922
+ firstAt: file.firstAt,
923
+ lastAt: file.lastAt,
924
+ lastTurn: file.lastTurn === undefined ? null : file.lastTurn,
925
+ turns: Array.isArray(file.turns) ? file.turns.slice() : [],
926
+ });
927
+ }
928
+ files.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0));
929
+ }
930
+ const possible = [];
931
+ if (entry !== undefined && !mismatched) {
932
+ for (const [relative, item] of entry.possible) {
933
+ possible.push({
934
+ path: relative,
935
+ file: relative,
936
+ hits: item.hits,
937
+ firstAt: item.firstAt,
938
+ lastAt: item.lastAt,
939
+ reason: item.reason,
940
+ lastTurn: item.lastTurn === undefined ? null : item.lastTurn,
941
+ });
942
+ }
943
+ possible.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0));
944
+ }
945
+ let unconfirmed = 0;
946
+ if (sessionId !== null && sessionId !== undefined) {
947
+ for (const intent of pendingChangeIntents.values())
948
+ if (intent.sessionId === sessionId)
949
+ unconfirmed += 1;
950
+ }
951
+ return {
952
+ repo: cwd,
953
+ sessionId: sessionId ?? null,
954
+ tracking: true,
955
+ // 记录是在宿主进程里做的、不持久化:客户端据此说"自 … 起",而不是暗示"这个会话从头到现在"。
956
+ since: sessionChangeTrackingSince,
957
+ // 这个会话第一次被记到改动的时刻(没有改动就是 null)。
958
+ firstChangeAt: entry === undefined ? null : entry.since,
959
+ files,
960
+ dropped: entry === undefined ? 0 : entry.dropped,
961
+ // 命令旁听的结果:`possible` 是"可能相关"的候选路径(启发式),`commands` 是跑过的命令。
962
+ possible,
963
+ possibleDropped: entry === undefined ? 0 : entry.possibleDropped,
964
+ commands: entry === undefined || mismatched ? [] : entry.commands.slice(),
965
+ unconfirmed,
966
+ cwdMismatch: mismatched,
967
+ };
968
+ }
969
+ /** 挂上两个事件监听(由 `apply` 调用)。 */
970
+ export function trackSessionChanges(ctx) {
971
+ return ctx.effect(() => {
972
+ sessionChangeTrackingSince = Date.now();
973
+ const stops = [
974
+ ctx.on('fs/write-intent', (target, exec) => {
975
+ noteChangeIntent(target, exec, 'write');
976
+ }),
977
+ ctx.on('fs/edit-intent', (target, exec) => {
978
+ noteChangeIntent(target, exec, 'edit');
979
+ }),
980
+ ctx.on('fs/observed', (target, observation, exec) => {
981
+ noteChangeObservation(target, exec);
982
+ }),
983
+ // 命令旁听:`tools/post-execute` 是 waterfall,**必须把 next() 的判定原样透传**
984
+ // (兜底是 `{ kind: 'accept' }`,吞掉就等于把工具结果的裁决改掉了)。
985
+ // 工具开跑:记"现在在跑哪个工具"(胶囊显示实时秒数用)。
986
+ // `tools/pre-execute` 同样是 waterfall(兜底 `{ kind: 'allow' }`),必须原样透传判定。
987
+ ctx.on('tools/pre-execute', (exec, next) => {
988
+ noteToolStart(exec);
989
+ return next();
990
+ }),
991
+ ctx.on('tools/post-execute', (exec, result, next) => {
992
+ noteShellCommand(exec, result);
993
+ noteToolFinish(exec, result);
994
+ return next();
995
+ }),
996
+ ];
997
+ // 软依赖全部集中在 services.js:状态只在那里被持有,这里只负责挂上并收集清理函数。
998
+ registerSoftDependencies(ctx, stops);
999
+ return () => {
1000
+ for (const stop of stops)
1001
+ if (typeof stop === 'function')
1002
+ stop();
1003
+ sessionChanges.clear();
1004
+ pendingChangeIntents.clear();
1005
+ sessionTools.clear();
1006
+ sessionChangeTrackingSince = null;
1007
+ };
1008
+ }, 'dsh-hud: session change tracking');
1009
+ }