@konnga/dsh-hud 0.7.1 → 0.8.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.
package/lib/index.js CHANGED
@@ -256,7 +256,10 @@ export async function readStatus(cwd) {
256
256
  return { repo: null, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, dirty: false, counts: emptyCounts(), changes: [], truncated: false }
257
257
  }
258
258
 
259
- const status = await runGit(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal'])
259
+ // `--untracked-files=all`:未跟踪**按文件**计,而不是把整个未跟踪目录折成一条。
260
+ // 不这样写的话,`?n` 会比编辑器(以及 `git status -uall`)报的数小得多 —— 目录里 200 个新文件
261
+ // 只算 1 条。代价是 git 要走一遍未跟踪树,所以清单仍受 `MAX_CHANGES` 上限保护(超出置 truncated)。
262
+ const status = await runGit(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'])
260
263
  if (!status.ok) {
261
264
  if (status.killed) throw Object.assign(new Error('git status timed out'), { code: 'timeout' })
262
265
  throw Object.assign(new Error(status.stderr.trim() || status.message || 'git status failed'), { code: 'git-failed' })
@@ -318,12 +321,29 @@ export async function readSessionOverview() {
318
321
  mine: entry === undefined ? 0 : entry.files.size,
319
322
  possible: entry === undefined ? 0 : entry.possible.size,
320
323
  }
321
- if (row.cwd === null) return { ...summary, repo: false, error: null }
324
+ /**
325
+ * 「可能相关」里**此刻仍然有差异**的个数(与这份 status 的清单求交)。
326
+ *
327
+ * 为什么要两个数:`possible` 是"命令里提到过的路径"(历史,含已经还原/被忽略的),
328
+ * 面板那一行只数"现在还有差异"的。同一个概念两个口径,界面上必须分别说清,
329
+ * 否则就会出现"工作区干净 + 14 个可能相关"这种让人以为算错了的组合。
330
+ */
331
+ const possibleNow = entry === undefined
332
+ ? 0
333
+ : (() => {
334
+ const status = row.cwd === null ? undefined : byCwd.get(row.cwd)
335
+ if (status === undefined || status === null || Array.isArray(status.changes) !== true) return 0
336
+ let count = 0
337
+ for (const change of status.changes) if (entry.possible.has(change.file)) count += 1
338
+ return count
339
+ })()
340
+ if (row.cwd === null) return { ...summary, possibleNow: 0, repo: false, error: null }
322
341
  const status = byCwd.get(row.cwd)
323
- if (status === undefined || status === null) return { ...summary, repo: false, error: null, pending: true }
324
- if (status.error !== undefined) return { ...summary, repo: false, error: status.error }
342
+ if (status === undefined || status === null) return { ...summary, possibleNow: 0, repo: false, error: null, pending: true }
343
+ if (status.error !== undefined) return { ...summary, possibleNow: 0, repo: false, error: status.error }
325
344
  return {
326
345
  ...summary,
346
+ possibleNow,
327
347
  repo: status.repo !== null,
328
348
  branch: status.branch,
329
349
  detached: status.detached === true,
@@ -758,6 +778,78 @@ async function resolveCommit(cwd, sha) {
758
778
  }
759
779
 
760
780
  /** 这次提交的改动清单 + 元信息(只读 `git diff` / `git show`)。 */
781
+ /** 标签列表上限(超出的只报 truncated,不硬塞)。 */
782
+ const TAGS_MAX = 100
783
+
784
+ /**
785
+ * 读标签列表(`refs/tags`,按 creatordate 倒序 = 最新的在前)。
786
+ *
787
+ * 两处容易搞错、这里显式处理:
788
+ * - **附注 tag**(annotated)的 `%(objectname)` 是 **tag 对象**的 sha,不是它指向的提交;
789
+ * 要拿提交得用 `%(*objectname)`。轻量 tag 两者相同。列表里显示提交 sha(能对上“最近提交”)。
790
+ * - 主题用 `%(contents:subject)`:附注 tag 是 tag 说明的首行,轻量 tag 拿到的是提交标题 ——
791
+ * 两种情况都比只给一个名字有用;它理论上可以是多行,所以最后一个字段按“剩下全部”再取首行。
792
+ */
793
+ export async function readTags(cwd, count = TAGS_MAX) {
794
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
795
+ if (!root.ok) {
796
+ if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
797
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
798
+ if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
799
+ return { repo: null, tags: [], truncated: false }
800
+ }
801
+
802
+ const repoRoot = root.stdout.split('\n')[0].trim()
803
+ if (!repoRoot) return { repo: null, tags: [], truncated: false }
804
+
805
+ const requested = Number.isFinite(count) ? Math.floor(count) : TAGS_MAX
806
+ const limit = Math.min(Math.max(1, requested), TAGS_MAX)
807
+
808
+ // 多取一条用来判断“还有更多”(比事后猜“正好等于上限”准确)。
809
+ const result = await runGit(repoRoot, [
810
+ 'for-each-ref',
811
+ '--sort=-creatordate',
812
+ `--count=${String(limit + 1)}`,
813
+ '--format=%(refname:short)%00%(objecttype)%00%(objectname:short)%00%(*objectname:short)%00%(creatordate:short)%00%(creatordate:unix)%00%(contents:subject)',
814
+ 'refs/tags',
815
+ ])
816
+ // 没有 tag 时 git 也返回 0(输出为空),不是错误。
817
+ if (!result.ok) return { repo: repoRoot, tags: [], truncated: false }
818
+
819
+ const lines = result.stdout.split('\n').filter(line => line.length > 0)
820
+ const tags = lines.map((line) => {
821
+ const parts = line.split('\0')
822
+ const [name, objecttype, objectname, peeled, date, seconds] = parts
823
+ // 最后一个字段是主题:附注 tag 的说明可能是多行,这里只取首行。
824
+ const subject = parts.slice(6).join('\0').split('\n')[0] ?? ''
825
+ const at = Number(seconds)
826
+ const annotated = objecttype === 'tag'
827
+ // 附注 tag 要“剥一层”拿提交 sha;剥不出来(比如 tag 指向 blob/tree)就退回对象自身的 sha。
828
+ const sha = annotated && peeled ? peeled : objectname
829
+ return {
830
+ name: name ?? '',
831
+ sha: sha ?? '',
832
+ annotated,
833
+ date: date ?? '',
834
+ committedAt: Number.isFinite(at) ? at * 1000 : null,
835
+ subject,
836
+ }
837
+ })
838
+
839
+ // creatordate 同一个秒内的先后 git 不保证(测试里三个 tag 常在同一秒建出来),
840
+ // 再按名字倒序定死 —— 否则界面上同批 tag 每次刷新都可能换位置。
841
+ //
842
+ // 顺序:**先排序再截断**。反过来的话截出来的是 git 的任意顺序(t1、t2),排序再好也没用。
843
+ tags.sort((left, right) => {
844
+ const diff = (right.committedAt ?? 0) - (left.committedAt ?? 0)
845
+ if (diff !== 0) return diff
846
+ return left.name < right.name ? 1 : (left.name > right.name ? -1 : 0)
847
+ })
848
+
849
+ const truncated = tags.length > limit
850
+ return { repo: repoRoot, tags: tags.slice(0, limit), truncated }
851
+ }
852
+
761
853
  export async function readCommit(cwd, sha) {
762
854
  const commit = await resolveCommit(cwd, sha)
763
855
  if (!commit.repo) return { repo: null, commit: null, files: [], truncated: false }
@@ -858,6 +950,161 @@ const SESSION_COMMAND_MAX = 20
858
950
  const COMMAND_TEXT_MAX = 140
859
951
  /** 一条命令里最多抽多少个路径(防御性上限)。 */
860
952
  const COMMAND_PATHS_MAX = 20
953
+ /** 每个会话留多少条"最近工具调用"(胶囊悬停里列最近几条)。 */
954
+ const TOOL_RECENT_MAX = 12
955
+ /** 最多同时记多少个会话的工具活动(与归因那份各自独立淘汰)。 */
956
+ const TOOL_SESSIONS_MAX = 32
957
+ /** 每个会话最多记多少个技能(超出的只计数不列名)。 */
958
+ const TOOL_SKILLS_MAX = 20
959
+ /** 参数摘要的显示上限(超长命令/路径截断,别把胶囊撑爆)。 */
960
+ const TOOL_SUMMARY_MAX = 60
961
+
962
+ /**
963
+ * sessionId → { active, recent, counts, total }:这个会话**跑到哪些工具**。
964
+ *
965
+ * 与归因那份记录分开存:归因关心"改了哪些文件",这份关心"跑了什么"。
966
+ */
967
+ const sessionTools = new Map()
968
+
969
+ /** 取(必要时新建)某个会话的工具活动记录,并做数量上限淘汰。 */
970
+ function sessionToolEntry(sessionId) {
971
+ let entry = sessionTools.get(sessionId)
972
+ if (entry === undefined) {
973
+ entry = { active: null, recent: [], counts: new Map(), status: new Map(), skills: new Map(), total: 0 }
974
+ sessionTools.set(sessionId, entry)
975
+ if (sessionTools.size > TOOL_SESSIONS_MAX) {
976
+ const oldest = sessionTools.keys().next().value
977
+ if (oldest !== undefined && oldest !== sessionId) sessionTools.delete(oldest)
978
+ }
979
+ }
980
+ return entry
981
+ }
982
+
983
+ /**
984
+ * 参数里可以安全拿出来做"一行摘要"的键。
985
+ *
986
+ * 只认这些**路径/命令类**的键:工具参数里可能带着整个文件内容(write 的 `content`),
987
+ * 随便取"第一个字符串"就会把内容贴到界面上 —— 所以认不出来就干脆不写摘要。
988
+ */
989
+ const TOOL_SUMMARY_KEYS = ['command', 'cmd', 'script', 'filePath', 'path', 'file', 'pattern', 'query', 'url']
990
+
991
+ /** 「调用技能」那件工具的工具名(`dsh-tool-skill` 注册的名字)。 */
992
+ const SKILL_TOOL_NAME = 'skill'
993
+
994
+ /**
995
+ * 技能名:`skill` 工具的入参是 `{ name: '技能名' }`。
996
+ *
997
+ * 只对这一个工具认 `name` 键 —— 别的工具用 `name` 表达别的东西,不能被当技能名抓走。
998
+ */
999
+ function skillNameOf(exec) {
1000
+ if (exec === null || typeof exec !== 'object' || exec.name !== SKILL_TOOL_NAME) return null
1001
+ const args = exec.arguments
1002
+ if (args === null || typeof args !== 'object') return null
1003
+ const value = args.name
1004
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim().slice(0, TOOL_SUMMARY_MAX) : null
1005
+ }
1006
+
1007
+ /** 从工具调用参数里取一行摘要(认不出来返回 null,不用兜底去猜)。 */
1008
+ function toolSummaryOf(exec) {
1009
+ const skill = skillNameOf(exec)
1010
+ if (skill !== null) return skill
1011
+ const args = exec && exec.arguments
1012
+ if (args === null || typeof args !== 'object') return null
1013
+ for (const key of TOOL_SUMMARY_KEYS) {
1014
+ const value = args[key]
1015
+ if (typeof value === 'string' && value.trim().length > 0) {
1016
+ return value.replace(/\s+/g, ' ').trim().slice(0, TOOL_SUMMARY_MAX)
1017
+ }
1018
+ }
1019
+ return null
1020
+ }
1021
+
1022
+ /** 工具开跑:记下"现在是哪个工具、什么时候开始的"(供胶囊显示实时秒数)。 */
1023
+ function noteToolStart(exec) {
1024
+ try {
1025
+ const identity = sessionIdentityOf(exec)
1026
+ if (identity === null) return
1027
+ const entry = sessionToolEntry(identity.sessionId)
1028
+ entry.active = {
1029
+ callId: identity.callId,
1030
+ name: typeof exec.name === 'string' && exec.name.length > 0 ? exec.name : '?',
1031
+ summary: toolSummaryOf(exec),
1032
+ startedAt: Date.now(),
1033
+ }
1034
+ entry.total += 1
1035
+ const name = entry.active.name
1036
+ entry.counts.set(name, (entry.counts.get(name) || 0) + 1)
1037
+ const skill = skillNameOf(exec)
1038
+ if (skill !== null) {
1039
+ const seen = entry.skills.get(skill)
1040
+ entry.skills.set(skill, { count: (seen ? seen.count : 0) + 1, ok: seen ? seen.ok : null })
1041
+ }
1042
+ } catch {
1043
+ // 旁听失败不影响工具本身。
1044
+ }
1045
+ }
1046
+
1047
+ /** 工具结束:把这一次挪进"最近",并清掉 active(只有同一个 callId 才清,避免并发调用互相抹掉)。 */
1048
+ function noteToolFinish(exec, result) {
1049
+ try {
1050
+ const identity = sessionIdentityOf(exec)
1051
+ if (identity === null) return
1052
+ const entry = sessionToolEntry(identity.sessionId)
1053
+ const active = entry.active
1054
+ const startedAt = active !== null && active.callId === identity.callId ? active.startedAt : null
1055
+ entry.recent.push({
1056
+ name: active !== null && active.callId === identity.callId ? active.name : (typeof exec.name === 'string' ? exec.name : '?'),
1057
+ summary: active !== null && active.callId === identity.callId ? active.summary : toolSummaryOf(exec),
1058
+ ok: !resultFailed(result),
1059
+ at: Date.now(),
1060
+ ms: startedAt === null ? null : Math.max(0, Date.now() - startedAt),
1061
+ })
1062
+ if (entry.recent.length > TOOL_RECENT_MAX) entry.recent.splice(0, entry.recent.length - TOOL_RECENT_MAX)
1063
+ // 每个工具"最近一次调用"的结果:胶囊上每个工具前面那个 ✓/✗ 用它。
1064
+ const finished = entry.recent[entry.recent.length - 1]
1065
+ if (finished !== undefined) entry.status.set(finished.name, finished.ok === true)
1066
+ // 技能自己的最近一次结果(胶囊上可以据此打 ✓/✗)。
1067
+ const skill = skillNameOf(exec)
1068
+ if (skill !== null && entry.skills.has(skill)) {
1069
+ entry.skills.set(skill, { count: entry.skills.get(skill).count, ok: !resultFailed(result) })
1070
+ }
1071
+ if (active !== null && active.callId === identity.callId) entry.active = null
1072
+ } catch {
1073
+ // 同上。
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * 读出某个会话的工具活动(端点载荷)。
1079
+ *
1080
+ * `active` 带 `startedAt`:实时秒数由客户端本地计时(否则要为了一个秒数把轮询压到亚秒级 ✗)。
1081
+ */
1082
+ export function readSessionTools(sessionId) {
1083
+ const entry = sessionId === null || sessionId === undefined ? undefined : sessionTools.get(sessionId)
1084
+ if (entry === undefined) {
1085
+ return { sessionId: sessionId ?? null, tracking: true, active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0 }
1086
+ }
1087
+ const counts = [...entry.counts].map(([name, count]) => ({
1088
+ name,
1089
+ count,
1090
+ // `null` = 还没看到它结束过(比如正在跑第一次),别替它编一个 ✓。
1091
+ ok: entry.status.has(name) ? entry.status.get(name) : null,
1092
+ }))
1093
+ counts.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1))
1094
+ // 技能按次数排序(用得多在前),超过上限只报前若干个。
1095
+ const skills = [...entry.skills].map(([name, item]) => ({ name, count: item.count, ok: item.ok }))
1096
+ skills.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1))
1097
+ return {
1098
+ sessionId,
1099
+ tracking: true,
1100
+ active: entry.active === null ? null : { ...entry.active },
1101
+ recent: entry.recent.slice(-5).reverse(),
1102
+ counts,
1103
+ skills: skills.slice(0, TOOL_SKILLS_MAX),
1104
+ skillsDropped: Math.max(0, skills.length - TOOL_SKILLS_MAX),
1105
+ total: entry.total,
1106
+ }
1107
+ }
861
1108
 
862
1109
  /** sessionId → { cwd, since, files: Map<relPath, {...}>, dropped, unconfirmed }。 */
863
1110
  const sessionChanges = new Map()
@@ -1316,8 +1563,15 @@ function trackSessionChanges(ctx) {
1316
1563
  }),
1317
1564
  // 命令旁听:`tools/post-execute` 是 waterfall,**必须把 next() 的判定原样透传**
1318
1565
  // (兜底是 `{ kind: 'accept' }`,吞掉就等于把工具结果的裁决改掉了)。
1566
+ // 工具开跑:记"现在在跑哪个工具"(胶囊显示实时秒数用)。
1567
+ // `tools/pre-execute` 同样是 waterfall(兜底 `{ kind: 'allow' }`),必须原样透传判定。
1568
+ ctx.on('tools/pre-execute', (exec, next) => {
1569
+ noteToolStart(exec)
1570
+ return next()
1571
+ }),
1319
1572
  ctx.on('tools/post-execute', (exec, result, next) => {
1320
1573
  noteShellCommand(exec, result)
1574
+ noteToolFinish(exec, result)
1321
1575
  return next()
1322
1576
  }),
1323
1577
  ]
@@ -1351,6 +1605,7 @@ function trackSessionChanges(ctx) {
1351
1605
  for (const stop of stops) if (typeof stop === 'function') stop()
1352
1606
  sessionChanges.clear()
1353
1607
  pendingChangeIntents.clear()
1608
+ sessionTools.clear()
1354
1609
  sessionChangeTrackingSince = null
1355
1610
  }
1356
1611
  }, 'dsh-hud: session change tracking')
@@ -1373,6 +1628,12 @@ const ENDPOINTS = {
1373
1628
  return status
1374
1629
  },
1375
1630
 
1631
+ async 'session-tools'(args) {
1632
+ const input = args && typeof args === 'object' ? args : {}
1633
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
1634
+ return readSessionTools(sessionId)
1635
+ },
1636
+
1376
1637
  async 'session-statuses'() {
1377
1638
  return readSessionOverview()
1378
1639
  },
@@ -1401,6 +1662,13 @@ const ENDPOINTS = {
1401
1662
  return readLog(cwd.value, input.count)
1402
1663
  },
1403
1664
 
1665
+ /** 标签列表(只读)。点一个 tag 复用 `commit` 那条管道去看它指向的提交。 */
1666
+ async tags(args) {
1667
+ const input = args && typeof args === 'object' ? args : {}
1668
+ const cwd = validateCwd(input.cwd)
1669
+ if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
1670
+ return readTags(cwd.value, input.count)
1671
+ },
1404
1672
  async commit(args) {
1405
1673
  const input = args && typeof args === 'object' ? args : {}
1406
1674
  const cwd = validateCwd(input.cwd)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@konnga/dsh-hud",
3
- "version": "0.7.1",
4
- "description": "DeepSeek Harness 的当前项目 Git 悬浮面板:分支、ahead/behind、脏状态与改动清单(只读),并告诉你这个会话改过哪些文件(按会话归因:确定改过 / 命令旁听的「可能相关」/ 与本会话无关,精确到第几轮)、审阅到哪了(已审 x/y),以及侧栏会话行的状态标记。A read-only Git HUD floating panel for DeepSeek Harness — branch, ahead/behind, dirty state, changed files — plus per-session change attribution (confirmed / command-inferred / per turn), review progress, and sidebar session status.",
3
+ "version": "0.8.0",
4
+ "description": "DeepSeek Harness 的只读工作区 Git 面板:分支、ahead/behind、改动清单与差异,外加「这个会话改过哪些文件、审到哪了、跑了什么工具」。A read-only workspace Git panel for DeepSeek Harness — branch, changed files, diffs, plus per-session change attribution, review progress, and tool activity.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -13,11 +13,13 @@
13
13
  "lib",
14
14
  "cordis.patch.yml",
15
15
  "README.md",
16
- "LICENSE"
16
+ "LICENSE",
17
+ "docs/images"
17
18
  ],
18
19
  "scripts": {
19
20
  "test": "node --test test/*.test.mjs",
20
- "prepublishOnly": "npm test"
21
+ "prepublishOnly": "npm test && node scripts/check-release.mjs",
22
+ "tag": "node scripts/check-release.mjs --create"
21
23
  },
22
24
  "keywords": [
23
25
  "dsh",
@@ -35,6 +37,14 @@
35
37
  ],
36
38
  "license": "MIT",
37
39
  "author": "konnga",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/konnga/dsh-hud.git"
43
+ },
44
+ "homepage": "https://github.com/konnga/dsh-hud#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/konnga/dsh-hud/issues"
47
+ },
38
48
  "publishConfig": {
39
49
  "access": "public"
40
50
  },