@tea-agent/loop-agent 0.44.0-next.4 → 0.44.0-next.5

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 (77) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/shell-write-guard.js +26 -3
  4. package/dist/shared/file-identity.js +23 -0
  5. package/dist/shared/git-workspace-status.js +480 -0
  6. package/dist/shared/path-safety.js +2 -2
  7. package/dist/worker/console/chat/context-insights.js +723 -0
  8. package/dist/worker/console/chat/repo-browser.js +3 -4
  9. package/dist/worker/console/chat/routes.js +73 -1
  10. package/dist/worker/console/chat/shortcuts.js +7 -0
  11. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B_lmJjOm.js → abnfDiagram-N423BO3Z-BHuhGM1e.js} +1 -1
  12. package/dist/worker/console/static/assets/{arc-pymM4Qeh.js → arc-CKh5E_sr.js} +1 -1
  13. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-Bw9EPAhi.js → architectureDiagram-T3A2C74G-DizhiBEv.js} +1 -1
  14. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C7_oXJp2.js → blockDiagram-VBNYF7ZC-3Neea-Z-.js} +1 -1
  15. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-LnLWDbh8.js → c4Diagram-5PPSVZJV-BCP_Qrgi.js} +1 -1
  16. package/dist/worker/console/static/assets/channel-DxgzA1GF.js +1 -0
  17. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BBiu3sCW.js → chunk-2GRJ4B5K-C_wydftK.js} +1 -1
  18. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DiXqDQml.js → chunk-2Q5K7J3B-jRPXZbzx.js} +1 -1
  19. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-lmNq9nYk.js → chunk-5RXB4S5H-BLQf2OYO.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-boBu74dU.js → chunk-5VM5RSS4-0qRq64rI.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-CjxqoJLQ.js → chunk-6Q2QTUOP-ClMg8xmV.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DL0vQ9MG.js → chunk-GF5L2VYU-Coop2FlL.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DGPqE-O0.js → chunk-JWPE2WC7-CDdO-jPJ.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-v4BbT_r2.js → chunk-KBJHAD2P-D8Gkppjl.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Ci0ynvyL.js → chunk-RYQCIY6F-DA9HIySf.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CGsbJoTV.js → chunk-XXDRQBXY-DZNDRX1k.js} +1 -1
  27. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BZcm7fs6.js +1 -0
  28. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BZcm7fs6.js +1 -0
  29. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CzoRDEI7.js → cose-bilkent-JH36ORCC-CZ1zGyjq.js} +1 -1
  30. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BOwFwbX4.js → cynefin-VYW2F7L2-B8u1_FtM.js} +1 -1
  31. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-2Cfg451l.js → cynefinDiagram-MW4NZA55-CPBB6lfo.js} +1 -1
  32. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-DbT0D6Y5.js → dagre-VZM6K2ZE-CG3Eov3E.js} +1 -1
  33. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dfd_JoMs.js → diagram-7IWD3JNH-ycI-dOjh.js} +1 -1
  34. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DaGm82kh.js → diagram-B4RE2ZJO-Bx_GyQ7Y.js} +1 -1
  35. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DMoI8Ibc.js → diagram-LBJQPF4R-m0G6mP4d.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtIFA3aY.js → diagram-Q27KOJAE-DTDJkqcJ.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-UB23O5K3-D_2X1DTZ.js → diagram-UB23O5K3-BCfFrG-p.js} +1 -1
  38. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-yFW5ZYlZ.js → ebnfDiagram-BXEA7PRR-BIkTNPpp.js} +1 -1
  39. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DBXh5Svj.js → erDiagram-JOGREHBK-DTcMWrd2.js} +1 -1
  40. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-C4ycm8KM.js → flowDiagram-UKHOOZJN-BqzDaNQ7.js} +1 -1
  41. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-qr-_Pekr.js → ganttDiagram-PKOTCBZU-BSl_vGFe.js} +1 -1
  42. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DzOSLv_A.js → gitGraphDiagram-DS77QQ5N-DCA5HXM6.js} +1 -1
  43. package/dist/worker/console/static/assets/{index-ZtyB6Ol-.css → index-BAEDjTbD.css} +1 -1
  44. package/dist/worker/console/static/assets/{index-PFjB6g6l.js → index-wKEJUaP5.js} +106 -106
  45. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-C7Hl8D7l.js → infoDiagram-6WML65LV-DvqT18ge.js} +1 -1
  46. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-fBxVlr6t.js → ishikawaDiagram-WSZJBQD7-B6zGKrQ3.js} +1 -1
  47. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CoiJEVCl.js → journeyDiagram-NVQOT4AX-BpLQPI9C.js} +1 -1
  48. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-TJXpxNuV.js → kanban-definition-27J2QSJJ-CzIz_-Lt.js} +1 -1
  49. package/dist/worker/console/static/assets/{linear-CjQWz1pG.js → linear-CMQ8Jpp-.js} +1 -1
  50. package/dist/worker/console/static/assets/{mermaid.core-DY9_apIE.js → mermaid.core-BdAMrTf7.js} +5 -5
  51. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-HB0Id8gM.js → mindmap-definition-FAOFIHXS-hSwF6sOq.js} +1 -1
  52. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-0OSWgr6v.js → pegDiagram-VL7TDLO6-DZdv509B.js} +1 -1
  53. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Cei1GGC2.js → pieDiagram-7S7Q4E2Y-CmD4-b0K.js} +1 -1
  54. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CDy0Adfu.js → quadrantDiagram-CIZ2JOQS-DtKxNxlS.js} +1 -1
  55. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwqzUWwN.js → railroadDiagram-AXF67PYL-D48iIoiS.js} +1 -1
  56. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-Bif2IBRL.js → requirementDiagram-LRYGKXZP-C3N0K-2p.js} +1 -1
  57. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CYIoNMLg.js → sankeyDiagram-W5VNT64P-CACWR-Xs.js} +1 -1
  58. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BDS7Y0aQ.js → sequenceDiagram-SI44F4Z6-g_QgugvZ.js} +1 -1
  59. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-lZic-_Bp.js → sizeCapture-X5ZJPWSS-DqLoRPGK.js} +1 -1
  60. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-WCVlCqSd.js → stateDiagram-OKZ733FA-ES59VgMG.js} +1 -1
  61. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyCIq6jx.js +1 -0
  62. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-pGccxTw_.js → swimlanes-SLNWSIFB-fjyLVNWo.js} +2 -2
  63. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CsKtO34q.js +8 -0
  64. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-D29RlrQA.js → timeline-definition-Z64GVDOM-Cd5-biKv.js} +1 -1
  65. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-01fEM1NU.js → vennDiagram-T6HMQDX7-CZTu6mPf.js} +1 -1
  66. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-C4GeB6dq.js → wardleyDiagram-T6FBY63Y-DR01jjbe.js} +1 -1
  67. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BXHrXHps.js → xychartDiagram-ELKLHX3M-iZcuGVGa.js} +1 -1
  68. package/dist/worker/console/static/index.html +2 -2
  69. package/dist/worker/console/static-src/operator-chat/context-insights-store.js +64 -0
  70. package/dist/worker/observe/node-context-insights.js +479 -0
  71. package/docs/README.md +1 -1
  72. package/package.json +1 -1
  73. package/dist/worker/console/static/assets/channel-Ym13M3Lb.js +0 -1
  74. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeFR1wGo.js +0 -1
  75. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeFR1wGo.js +0 -1
  76. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-cj5UUB6m.js +0 -1
  77. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-C_Qp6bnh.js +0 -8
package/CHANGELOG.md CHANGED
@@ -2,11 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ - 修复 Windows 上 pi 节点零提交失败的读写守卫误报:libuv 在 win32 的 `fstat` 返回卷序列号而 `lstat` 恒为 0,跨 API 比较必然失败并误报“文件在打开期间被替换”;现新建共享身份比较 helper,win32 跳过跨 API `dev` 比较但保留 `ino` 与其余全部防护,Console 仓库预览同类比较一并修复(用户 0.42.1/0.43.0 两次实测上报)。
6
+ - 修复研发工作流对 Git submodule 的误拦截:父仓库 `git status` 只能看到 submodule 目录级条目,子仓库内合法写入会被误判 `empty-diff`(脏基线)或越界(净基线),导致 verify/review/closeout 全部跳过;现在状态采集会展开到已初始化子仓库的文件级事实(含嵌套 submodule、中文/带引号路径解码、未初始化与采集失败时如实报错不吞证据),合法子仓库写入正常进入验证链,真实越界仍按文件精确拦截,无 submodule 仓库行为字节级不变。
7
+
5
8
  - Console 新增低饱和的「暮色蓝」深色与「雾光白」浅色主题,保留默认暖纸色;柔和背景、轻量选中态与独立按钮配色减少视觉刺激。顶部仅显示带「主题」悬停提示的紧凑明暗图标按钮,点击后展开「默认 / 深色 / 浅色」,选完自动收起,支持点击外部或 Escape 关闭;初始使用默认主题;即时切换对话、操作与观测界面,刷新后记住选择,浏览器存储不可用时仍可在当前页面切换。
6
9
  - Operator Chat 消息流后续对齐:折叠摘要现在统计 retry/step/command/unknown 等 durable 过程节点,Trajectory 与 HTML 导出保留 DAG progress,并使用稳定过程身份避免直播插入导致 Thinking/工具行重挂载。
7
10
  - Operator Chat 统一主时间线、Trajectory 与导出使用同一 DSH flow projection:Thinking、工具、重试、命令和完成态节点按事件序交错,保留 completed-turn 折叠;request/unknown 详情改为有界结构化摘要,不再回显裸节点 JSON。
8
11
  - Operator Chat 的消息流对齐 DSH Conversation Node:模型步骤、重试、终止错误、输出上限、每次请求上下文、嵌套工具、`/sdd-bingo` 命令回执和未知事件均以稳定节点留痕;同一模型步骤的多次重试聚合为一条紧凑记录,Provider 终态错误改为轻量行且不再暴露原始响应;流式文本按动画帧合并发布,刷新和断线恢复保持同一身份与顺序,轨迹页面和导出同步展示。
9
- - Operator Chat 在后台断线重连后不再丢消息:检测到中断会自动补齐缺口内容,也不会把正常的历史回放误判成丢事件而反复重连。
12
+ - Operator Chat 在后台断线重连后不再丢消息:检测到中断会自动补齐缺口内容,也不会把正常的历史回放误判成丢事件而反复重连。
13
+ - Operator Chat 新增独立「上下文洞察」面板:提供当前上下文构成、按步/按轮趋势与累计/增量视图、上下文浏览器(系统提示、工具 Schema、消息、注入、工具结果、技能)、上下文事件筛选、文件活动筛选排序,以及 token/cache/cost/timing 统计;`/context` 与详情栏「洞察」共用同一数据面。
14
+ - 「上下文洞察」新增 token 构成饼图(conic-gradient 渲染)、剪枝/压缩明细(区分 prune 与 compact 事件,展示前/后/回收 tokens、移除项数、原因);趋势柱 hover/click 实时联动上下文浏览器。
10
15
  - Operator Chat 的恢复快照现在只会合并属于目标会话的乐观提交;切换会话后,旧会话尚未确认的输入不会再因延迟重连恢复而显示到新会话中。
11
16
  - `/sdd-bingo` 成功提示现在展示上报的 Skill 与 Session ID;轨迹顶部会出现当前会话的常驻 SDD 身份栏,可一键复制、首次请求后自动更新;该上报以独立“操作”事件留痕,刷新或导出后仍在,不再伪装成工具调用。
12
17
  - Operator Chat 发送消息后的空白光标改为 Codex 式“正在思考”,真实思考、工具过程或最终回答出现后自然接替;并修复了状态偶发显示在用户输入上方的问题。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.44.0-next.4",
4
- "gitSha": "6d1cd2f6dd13854cd48bcd4b38718a53cc24109a",
5
- "builtAt": "2026-09-15T17:21:15.173Z"
3
+ "version": "0.44.0-next.5",
4
+ "gitSha": "da4e19a774406e1fe79611807fd241e56e6e4c17",
5
+ "builtAt": "2026-09-16T02:11:12.294Z"
6
6
  }
@@ -4,6 +4,7 @@ import { constants, createReadStream } from "node:fs";
4
4
  import { access, lstat, readlink, unlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { pathMatchesPattern } from "../shared/git-progress.js";
7
+ import { expandWorkspaceStatus, hasGitmodulesFile, synthesizeExpandedPorcelain, } from "../shared/git-workspace-status.js";
7
8
  import { ResilientGitCommandError, runResilientGitCommand, } from "../shared/resilient-git.js";
8
9
  async function sha256File(filePath) {
9
10
  const hash = createHash("sha256");
@@ -411,6 +412,7 @@ async function resolveGitExecutableCandidates(platform, env) {
411
412
  return ["git"];
412
413
  }
413
414
  export async function readGitStatusPorcelain(cwd, options = {}) {
415
+ let parentPorcelain;
414
416
  try {
415
417
  const result = await runResilientGitCommand({
416
418
  cwd,
@@ -419,9 +421,12 @@ export async function readGitStatusPorcelain(cwd, options = {}) {
419
421
  attempts: options.attempts,
420
422
  retryDelayMs: options.retryDelayMs,
421
423
  });
422
- if (result.code === 0)
423
- return result.stdout;
424
- throw new Error(result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`);
424
+ if (result.code === 0) {
425
+ parentPorcelain = result.stdout;
426
+ }
427
+ else {
428
+ throw new Error(result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`);
429
+ }
425
430
  }
426
431
  catch (error) {
427
432
  if (error instanceof ResilientGitCommandError) {
@@ -436,6 +441,24 @@ export async function readGitStatusPorcelain(cwd, options = {}) {
436
441
  }
437
442
  throw error;
438
443
  }
444
+ // Submodule workspaces: a parent-only porcelain hides every file inside a
445
+ // registered submodule behind a single directory-level gitlink entry,
446
+ // which historically produced false empty-diff and false violations
447
+ // (plan 2026-09-15-development-workflow-git-submodule). Repositories
448
+ // without .gitmodules keep the parent bytes verbatim — byte-identical
449
+ // behavior for every non-submodule workspace.
450
+ if (!(await hasGitmodulesFile(cwd)))
451
+ return parentPorcelain;
452
+ const observation = await expandWorkspaceStatus({
453
+ rootCwd: cwd,
454
+ parentPorcelain,
455
+ hasGitmodulesFile: true,
456
+ });
457
+ if (!observation.complete) {
458
+ const failed = observation.repos.find((repo) => repo.state === "status-read-failed");
459
+ throw new Error(`workspace status expansion incomplete: submodule ${failed?.repoRelativePath ?? "unknown"} status read failed: ${failed?.errorDetail ?? "unknown error"}`);
460
+ }
461
+ return synthesizeExpandedPorcelain(observation.entries);
439
462
  }
440
463
  export async function readGitStatusPorcelainWithDependencies(cwd, options, dependencies) {
441
464
  const candidates = await dependencies.resolveExecutableCandidates();
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Compare two stat records as the same file identity.
3
+ *
4
+ * POSIX: `dev` and `ino` together identify the inode; both must match.
5
+ *
6
+ * win32: libuv reports `dev` inconsistently across stat APIs — `fstat()`
7
+ * (from an open handle) returns the volume serial number while `lstat()`
8
+ * (from a path) returns 0. A cross-API comparison therefore always fails on
9
+ * Windows even when the path still refers to the same file, breaking every
10
+ * guard that rechecks identity after opening (user-reported on 0.42.1 and
11
+ * 0.43.0, Node 23.x). On win32 the `dev` field is skipped and `ino` plus the
12
+ * caller-side type/link/realpath checks remain the identity boundary.
13
+ *
14
+ * When both stats come from the same API the comparison is exact on every
15
+ * platform; the win32 relaxation only affects cross-API rechecks.
16
+ */
17
+ export function sameFileIdentity(opened, current, platform = process.platform) {
18
+ if (current.ino !== opened.ino)
19
+ return false;
20
+ if (platform !== "win32" && current.dev !== opened.dev)
21
+ return false;
22
+ return true;
23
+ }
@@ -0,0 +1,480 @@
1
+ import { access, lstat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { runResilientGitCommand } from "./resilient-git.js";
4
+ const MAX_SUBMODULE_DEPTH = 4;
5
+ export const DEFAULT_WORKSPACE_STATUS_DEPENDENCIES = {
6
+ runGit: (cwd, args) => runResilientGitCommand({ cwd, args, readOnly: true }).then((result) => ({
7
+ code: result.code,
8
+ stdout: result.stdout,
9
+ stderr: result.stderr,
10
+ })),
11
+ readSubmoduleStatus: async (cwd) => {
12
+ const result = await runResilientGitCommand({
13
+ cwd,
14
+ args: ["status", "--porcelain=v1", "--untracked-files=all"],
15
+ readOnly: true,
16
+ });
17
+ if (result.code !== 0) {
18
+ throw new Error(result.stderr.trim() ||
19
+ result.stdout.trim() ||
20
+ `git status exited ${result.code}`);
21
+ }
22
+ return result.stdout;
23
+ },
24
+ };
25
+ /** Parse porcelain v1 output into normalized path -> "XY" entries. */
26
+ export function parsePorcelainEntries(porcelain) {
27
+ const entries = new Map();
28
+ for (const line of porcelain.split("\n")) {
29
+ const trimmed = line.trimEnd();
30
+ if (!trimmed)
31
+ continue;
32
+ const x = trimmed[0] ?? " ";
33
+ const y = trimmed[1] ?? " ";
34
+ const rawPath = trimmed.slice(3);
35
+ if (!rawPath)
36
+ continue;
37
+ const normalized = normalizeWorkspacePath(unquoteGitPath(renameTargetPath(rawPath)));
38
+ if (!normalized)
39
+ continue;
40
+ entries.set(normalized, `${x}${y}`);
41
+ }
42
+ return entries;
43
+ }
44
+ /**
45
+ * Return the destination path of a porcelain path field. Rename entries
46
+ * render as `ORIG -> DEST` where either side may independently be C-quoted;
47
+ * splitting on a quoted ` -> ` (or unquoting before splitting) corrupts the
48
+ * key with residual quotes or half-decoded octal. Scan for the last
49
+ * outside-quote separator instead, then return the still-quoted DEST for
50
+ * the caller to unquote.
51
+ */
52
+ function renameTargetPath(rawPath) {
53
+ let inQuotes = false;
54
+ let separatorIndex = -1;
55
+ for (let i = 0; i < rawPath.length - 3; i += 1) {
56
+ const char = rawPath[i];
57
+ if (char === "\\") {
58
+ i += 1; // skip the escaped character; it cannot open/close quotes
59
+ continue;
60
+ }
61
+ if (char === '"')
62
+ inQuotes = !inQuotes;
63
+ if (!inQuotes && rawPath.startsWith(" -> ", i))
64
+ separatorIndex = i;
65
+ }
66
+ if (separatorIndex === -1)
67
+ return rawPath;
68
+ return rawPath.slice(separatorIndex + 4);
69
+ }
70
+ function normalizeWorkspacePath(value) {
71
+ const target = value.includes(" -> ")
72
+ ? (value.split(" -> ").at(-1) ?? value)
73
+ : value;
74
+ // Embedded-repo untracked entries render as `dir/`; strip the trailing
75
+ // slash so the key matches the registered submodule path form.
76
+ return target.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
77
+ }
78
+ /**
79
+ * Unquote a git C-quoted path (`"..."` with C-style escapes; non-ASCII bytes
80
+ * arrive as octal like \346\225\260). Returns the input unchanged when not
81
+ * quoted. This keeps Chinese/space paths (e.g. `source/需求.md`) comparable
82
+ * against writeSet patterns.
83
+ */
84
+ export function unquoteGitPath(value) {
85
+ if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
86
+ return value;
87
+ }
88
+ const inner = value.slice(1, -1);
89
+ let out = "";
90
+ for (let i = 0; i < inner.length; i += 1) {
91
+ const char = inner[i];
92
+ if (char !== "\\") {
93
+ out += char;
94
+ continue;
95
+ }
96
+ const next = inner[i + 1];
97
+ if (next === undefined)
98
+ break;
99
+ if (next === "n")
100
+ out += "\n";
101
+ else if (next === "t")
102
+ out += "\t";
103
+ else if (next === "r")
104
+ out += "\r";
105
+ else if (next === "\\")
106
+ out += "\\";
107
+ else if (next === '"')
108
+ out += '"';
109
+ else if (/[0-7]{3}/.test(inner.slice(i + 1, i + 4))) {
110
+ // Octal byte escape: collect consecutive escapes into one string
111
+ // and decode as UTF-8. `continue` skips the shared `i += 1` below
112
+ // (i already points past the consumed run).
113
+ const bytes = [];
114
+ let j = i;
115
+ while (j < inner.length && inner[j] === "\\" && /[0-7]{3}/.test(inner.slice(j + 1, j + 4))) {
116
+ bytes.push(Number.parseInt(inner.slice(j + 1, j + 4), 8));
117
+ j += 4;
118
+ }
119
+ out += Buffer.from(bytes).toString("utf8");
120
+ i = j - 1;
121
+ continue;
122
+ }
123
+ else {
124
+ out += next;
125
+ }
126
+ i += 1;
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * Read registered submodule directories from `.gitmodules` via
132
+ * `git config -f .gitmodules --get-regexp` (structured output; no
133
+ * shell-quoting hazards). Returns [] when the file is absent or empty.
134
+ */
135
+ export async function readSubmoduleRegistrations(rootCwd, dependencies = DEFAULT_WORKSPACE_STATUS_DEPENDENCIES) {
136
+ const result = await dependencies.runGit(rootCwd, [
137
+ "config",
138
+ "-f",
139
+ ".gitmodules",
140
+ "--get-regexp",
141
+ "^submodule\\..*\\.path$",
142
+ ]);
143
+ // Exit 1 with no stderr = the file exists but has no matching keys.
144
+ // Any other non-zero exit (unreadable file, parse error) must fail closed:
145
+ // returning [] would silently drop every submodule from observation.
146
+ if (result.code === 1 && !result.stderr.trim())
147
+ return [];
148
+ if (result.code !== 0) {
149
+ throw new Error(`git config -f .gitmodules failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
150
+ }
151
+ if (!result.stdout.trim())
152
+ return [];
153
+ const paths = [];
154
+ for (const line of result.stdout.split("\n")) {
155
+ const trimmed = line.trim();
156
+ if (!trimmed)
157
+ continue;
158
+ // Line format: `submodule.<name>.path <value>`. The value may contain
159
+ // spaces; split on the first space after the key only.
160
+ const separator = trimmed.indexOf(" ");
161
+ if (separator <= 0)
162
+ continue;
163
+ const submodulePath = trimmed.slice(separator + 1).trim();
164
+ if (!submodulePath)
165
+ continue;
166
+ paths.push(normalizeWorkspacePath(submodulePath));
167
+ }
168
+ return paths;
169
+ }
170
+ /**
171
+ * Probe the gitlink state of one submodule against its parent repository.
172
+ * Porcelain codes cannot distinguish "dirty content inside the submodule"
173
+ * (covered by the child's file-level entries) from "the child HEAD actually
174
+ * moved" (a git mutation the child's own clean status cannot express), so
175
+ * the relevant SHAs are compared directly.
176
+ *
177
+ * Three facts are captured so staged gitlinks and repeated moves cannot
178
+ * hide a mutation:
179
+ * - `indexSha`: what the parent index records for the gitlink now.
180
+ * - `headTreeSha`: what the parent HEAD tree records (detects `git add sub`
181
+ * staged after a commit — the working gitlink moved but index == HEAD is
182
+ * not the full story; see gitlinkMutated).
183
+ * - `headSha`: the child's checked-out HEAD.
184
+ *
185
+ * `readFailed` maps to the caller's fail-closed incomplete path.
186
+ */
187
+ async function readGitlinkState(parentRoot, submodulePath, dependencies) {
188
+ const ls = await dependencies.runGit(parentRoot, [
189
+ "ls-files",
190
+ "-s",
191
+ "--",
192
+ submodulePath,
193
+ ]);
194
+ const line = ls.code !== 0
195
+ ? undefined
196
+ : ls.stdout.split("\n").find((entry) => entry.trim());
197
+ // `160000 <sha> 0\t<path>` for a gitlink entry.
198
+ const indexSha = line?.split(/\s+/)[1];
199
+ const headTree = await dependencies.runGit(parentRoot, [
200
+ "rev-parse",
201
+ `HEAD:${submodulePath}`,
202
+ ]);
203
+ const headTreeSha = headTree.code === 0 ? headTree.stdout.trim() : undefined;
204
+ const rev = await dependencies.runGit(joinFsPath(parentRoot, submodulePath), ["rev-parse", "HEAD"]);
205
+ const headSha = rev.code === 0 ? rev.stdout.trim() : undefined;
206
+ if (ls.code !== 0 || rev.code !== 0) {
207
+ return {
208
+ indexSha,
209
+ headTreeSha,
210
+ headSha,
211
+ readFailed: true,
212
+ errorDetail: `ls-files exit ${ls.code}; rev-parse exit ${rev.code}: ${rev.stderr.trim() || ls.stderr.trim() || "no output"}`,
213
+ };
214
+ }
215
+ return { indexSha, headTreeSha, headSha, readFailed: false };
216
+ }
217
+ /**
218
+ * A gitlink mutation is any state where the working gitlink (child HEAD)
219
+ * disagrees with what the parent's committed or staged history expects, or
220
+ * where the parent index gitlink is itself dirty relative to HEAD:
221
+ * - child HEAD != index: writer moved the submodule HEAD (committed inside
222
+ * the submodule, or checked out another commit).
223
+ * - index != HEAD tree: the gitlink change was staged (`git add sub`) but
224
+ * not committed — still an unauthorized git mutation during a run.
225
+ * - child HEAD != HEAD tree (index clean): a moved HEAD not yet staged;
226
+ * the classic in-submodule commit.
227
+ * When the parent has never committed the submodule (fresh `submodule add`,
228
+ * no `HEAD:<path>`), only the index-vs-child comparison applies.
229
+ */
230
+ function gitlinkMutated(state) {
231
+ const { indexSha, headTreeSha, headSha } = state;
232
+ if (indexSha !== undefined && headSha !== undefined && indexSha !== headSha) {
233
+ return true;
234
+ }
235
+ if (headTreeSha !== undefined &&
236
+ indexSha !== undefined &&
237
+ headTreeSha !== indexSha) {
238
+ return true;
239
+ }
240
+ if (headTreeSha !== undefined &&
241
+ headSha !== undefined &&
242
+ headTreeSha !== headSha) {
243
+ return true;
244
+ }
245
+ return false;
246
+ }
247
+ /**
248
+ * Sentinel key for a mutated gitlink. Embeds the observed SHA triple so the
249
+ * key changes when the gitlink moves again (a constant key would make a
250
+ * second move on a dirty baseline indistinguishable from no move).
251
+ */
252
+ function gitlinkSentinelKey(workspacePath, state) {
253
+ const fingerprint = [state.indexSha, state.headTreeSha, state.headSha]
254
+ .map((sha) => sha ?? "-")
255
+ .join("+");
256
+ return `${GITLINK_SENTINEL_PREFIX}${workspacePath}@${fingerprint}`;
257
+ }
258
+ async function submoduleWorkingTreeInitialized(rootCwd, submodulePath) {
259
+ try {
260
+ const info = await lstat(path.resolve(rootCwd, ...submodulePath.split("/"), ".git"));
261
+ return info.isFile() || info.isDirectory();
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ }
267
+ async function collectSubmoduleEntries(input) {
268
+ // undefined = collection failed for this submodule (recorded, fail-closed).
269
+ const repoRoot = joinFsPath(input.rootCwd, input.workspacePath);
270
+ try {
271
+ const porcelain = await input.dependencies.readSubmoduleStatus(repoRoot);
272
+ input.repos.push({
273
+ repoRelativePath: input.repoWorkspacePath,
274
+ repoId: input.repoWorkspacePath,
275
+ state: "clean-status-read",
276
+ porcelain,
277
+ });
278
+ const childEntries = parsePorcelainEntries(porcelain);
279
+ const expanded = new Map();
280
+ for (const [childPath, code] of childEntries) {
281
+ expanded.set(`${input.repoWorkspacePath}/${childPath}`, code);
282
+ }
283
+ if (input.depth < MAX_SUBMODULE_DEPTH) {
284
+ const nestedPaths = await readSubmoduleRegistrations(repoRoot, input.dependencies);
285
+ for (const nestedPath of nestedPaths) {
286
+ const nestedWorkspacePath = `${input.repoWorkspacePath}/${nestedPath}`;
287
+ const initialized = await submoduleWorkingTreeInitialized(repoRoot, nestedPath);
288
+ if (!initialized) {
289
+ input.repos.push({
290
+ repoRelativePath: nestedWorkspacePath,
291
+ repoId: nestedWorkspacePath,
292
+ state: "uninitialized",
293
+ porcelain: "",
294
+ });
295
+ continue;
296
+ }
297
+ // Gitlink probe at this nesting level: the parent of `nested` is the
298
+ // current repository (repoRoot). Same three-SHA semantics as the
299
+ // top level; a moved nested HEAD surfaces as a SHA-tagged sentinel.
300
+ const nestedGitlink = await readGitlinkState(repoRoot, nestedPath, input.dependencies);
301
+ if (nestedGitlink.readFailed) {
302
+ input.repos.push({
303
+ repoRelativePath: nestedWorkspacePath,
304
+ repoId: nestedWorkspacePath,
305
+ state: "status-read-failed",
306
+ porcelain: "",
307
+ errorDetail: `gitlink probe failed: ${nestedGitlink.errorDetail ?? "unknown"}`,
308
+ });
309
+ continue;
310
+ }
311
+ const nestedEntries = await collectSubmoduleEntries({
312
+ rootCwd: repoRoot,
313
+ workspacePath: nestedPath,
314
+ repoWorkspacePath: nestedWorkspacePath,
315
+ depth: input.depth + 1,
316
+ repos: input.repos,
317
+ dependencies: input.dependencies,
318
+ });
319
+ if (nestedEntries === undefined) {
320
+ // Nested collection failures are recorded in the shared
321
+ // repos array with state status-read-failed; completeness
322
+ // is derived from those states after the top-level loop.
323
+ continue;
324
+ }
325
+ for (const [nestedFile, code] of nestedEntries) {
326
+ // nestedEntries keys are already full workspace paths
327
+ // (prefixed with nestedWorkspacePath).
328
+ expanded.set(nestedFile, code);
329
+ }
330
+ if (gitlinkMutated(nestedGitlink)) {
331
+ expanded.set(gitlinkSentinelKey(nestedWorkspacePath, nestedGitlink), "M ");
332
+ }
333
+ // Drop the child's own directory-level entry for the nested
334
+ // submodule (gitlink ` M nested` or embedded `?? nested/`);
335
+ // its file facts now come from the nested repository.
336
+ expanded.delete(`${input.repoWorkspacePath}/${nestedPath}`);
337
+ }
338
+ }
339
+ return expanded;
340
+ }
341
+ catch (error) {
342
+ input.repos.push({
343
+ repoRelativePath: input.repoWorkspacePath,
344
+ repoId: input.repoWorkspacePath,
345
+ state: "status-read-failed",
346
+ porcelain: "",
347
+ errorDetail: error instanceof Error ? error.message : String(error),
348
+ });
349
+ return undefined;
350
+ }
351
+ }
352
+ function joinFsPath(root, workspacePath) {
353
+ return path.resolve(root, ...workspacePath.split("/"));
354
+ }
355
+ /**
356
+ * Expand a parent porcelain snapshot into workspace file-level facts.
357
+ * Callers pass the already-captured parent porcelain so the parent bytes are
358
+ * captured once and reused.
359
+ */
360
+ export async function expandWorkspaceStatus(input) {
361
+ const dependencies = input.dependencies ?? DEFAULT_WORKSPACE_STATUS_DEPENDENCIES;
362
+ const parentEntries = parsePorcelainEntries(input.parentPorcelain);
363
+ const repos = [
364
+ {
365
+ repoRelativePath: "",
366
+ repoId: "parent",
367
+ state: "clean-status-read",
368
+ porcelain: input.parentPorcelain,
369
+ },
370
+ ];
371
+ const entries = new Map();
372
+ let complete = true;
373
+ const registrations = input.hasGitmodulesFile
374
+ ? await readSubmoduleRegistrations(input.rootCwd, dependencies)
375
+ : [];
376
+ for (const registration of registrations) {
377
+ const initialized = await submoduleWorkingTreeInitialized(input.rootCwd, registration);
378
+ if (!initialized) {
379
+ repos.push({
380
+ repoRelativePath: registration,
381
+ repoId: registration,
382
+ state: "uninitialized",
383
+ porcelain: "",
384
+ });
385
+ continue;
386
+ }
387
+ // Gitlink probe: SHA-compare the parent index gitlink against the
388
+ // child's checked-out HEAD. Only a real HEAD move (e.g. the writer
389
+ // committed inside the submodule) becomes a sentinel; dirty child
390
+ // content stays covered by the child's file-level entries.
391
+ const gitlink = await readGitlinkState(input.rootCwd, registration, dependencies);
392
+ if (gitlink.readFailed) {
393
+ repos.push({
394
+ repoRelativePath: registration,
395
+ repoId: registration,
396
+ state: "status-read-failed",
397
+ porcelain: "",
398
+ errorDetail: `gitlink probe failed: ${gitlink.errorDetail ?? "unknown"}`,
399
+ });
400
+ complete = false;
401
+ continue;
402
+ }
403
+ const submoduleEntries = await collectSubmoduleEntries({
404
+ rootCwd: input.rootCwd,
405
+ workspacePath: registration,
406
+ repoWorkspacePath: registration,
407
+ depth: 1,
408
+ repos,
409
+ dependencies,
410
+ });
411
+ if (submoduleEntries === undefined) {
412
+ complete = false;
413
+ continue;
414
+ }
415
+ for (const [workspacePath, code] of submoduleEntries) {
416
+ entries.set(workspacePath, code);
417
+ }
418
+ if (gitlinkMutated(gitlink)) {
419
+ entries.set(gitlinkSentinelKey(registration, gitlink), "M ");
420
+ }
421
+ }
422
+ // Completeness is derived from the recorded repo states: any
423
+ // status-read-failed repository at any depth (top-level or nested)
424
+ // makes the observation incomplete, so nested failures bubble up.
425
+ // (.gitmodules read failures throw and fail the whole observation.)
426
+ const completeNow = complete &&
427
+ !repos.some((repo) => repo.state === "status-read-failed");
428
+ // Parent entries: keep everything except directory-level gitlink entries
429
+ // of submodules whose own status was read successfully AND whose gitlink
430
+ // is unchanged. When the parent still reports the submodule as modified
431
+ // (gitlink differs from the index — the writer committed inside the
432
+ // submodule, or checked out another commit), keep a sentinel entry so the
433
+ // change stays visible in the expanded delta instead of silently
434
+ // vanishing behind the child's clean status. The child's clean file-level
435
+ // status cannot distinguish "gitlink untouched" from "gitlink moved";
436
+ // only the parent entry can, so it is preserved whenever non-clean.
437
+ const expandedRegistrations = new Set(repos
438
+ .filter((repo) => repo.repoRelativePath !== "" &&
439
+ repo.state === "clean-status-read")
440
+ .map((repo) => repo.repoRelativePath));
441
+ for (const [parentPath, code] of parentEntries) {
442
+ if (expandedRegistrations.has(parentPath)) {
443
+ // Directory-level submodule entries are dropped: file-level facts
444
+ // come from the child repos, and a moved gitlink is surfaced by the
445
+ // SHA probe as a sentinel during the registration loop. Porcelain
446
+ // codes cannot distinguish dirty content from a moved HEAD, so no
447
+ // code-based sentinel may be derived here.
448
+ continue;
449
+ }
450
+ if (entries.has(parentPath))
451
+ continue; // child facts take precedence
452
+ entries.set(parentPath, code);
453
+ }
454
+ return {
455
+ parentPorcelain: input.parentPorcelain,
456
+ entries,
457
+ repos,
458
+ complete: completeNow,
459
+ };
460
+ }
461
+ /** Prefix marking a preserved parent gitlink fact (mutated submodule HEAD). */
462
+ export const GITLINK_SENTINEL_PREFIX = "\u0000gitlink\u0000/";
463
+ /** Serialize expanded entries back into sorted porcelain-v1-shaped lines. */
464
+ export function synthesizeExpandedPorcelain(entries) {
465
+ const lines = [];
466
+ for (const [filePath, code] of [...entries.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
467
+ lines.push(`${code} ${filePath}`);
468
+ }
469
+ return lines.join("\n") + (lines.length > 0 ? "\n" : "");
470
+ }
471
+ /** Check for a `.gitmodules` file at the repository root. */
472
+ export async function hasGitmodulesFile(rootCwd) {
473
+ try {
474
+ await access(path.resolve(rootCwd, ".gitmodules"));
475
+ return true;
476
+ }
477
+ catch {
478
+ return false;
479
+ }
480
+ }
@@ -1,6 +1,7 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
2
  import { lstat, open, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { sameFileIdentity } from "./file-identity.js";
4
5
  function assertContained(root, target, label) {
5
6
  if (target === root || !target.startsWith(`${root}${path.sep}`)) {
6
7
  throw new Error(`${label}: path escapes boundary: ${target}`);
@@ -81,8 +82,7 @@ export async function readFileBoundarySafe(input) {
81
82
  if (current.isSymbolicLink() ||
82
83
  !current.isFile() ||
83
84
  current.nlink !== 1 ||
84
- current.dev !== opened.dev ||
85
- current.ino !== opened.ino) {
85
+ !sameFileIdentity(opened, current)) {
86
86
  throw new Error(`${input.label}: path changed while opening`);
87
87
  }
88
88
  return await handle.readFile();