@logicseek/hello-cc 1.1.0-rc.1 → 1.1.0-rc.4
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/CHANGELOG.md +229 -108
- package/README.md +1 -10
- package/README.zh-CN.md +1 -8
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +13 -28
- package/docs/commands.zh-CN.md +11 -20
- package/docs/dsh.md +5 -3
- package/docs/dsh.zh-CN.md +6 -4
- package/docs/macos-process-identity.md +15 -0
- package/docs/native.md +176 -6
- package/docs/native.zh-CN.md +138 -3
- package/docs/web-handoff.zh-CN.md +107 -5
- package/lib/cli/app.mjs +9 -6
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/gc.mjs +14 -12
- package/lib/cli/commands/hook.mjs +16 -3
- package/lib/cli/commands/install.mjs +37 -9
- package/lib/cli/commands/lock.mjs +3 -1
- package/lib/cli/commands/misc.mjs +105 -13
- package/lib/cli/commands/native.mjs +41 -48
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/gc-plan.mjs +5 -0
- package/lib/core/coordination/locks.mjs +3 -1
- package/lib/core/peers/evidence.mjs +21 -1
- package/lib/core/peers/tmux-safety.mjs +4 -0
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +67 -21
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +20 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/dsh-collaboration.mjs +3 -4
- package/lib/integrations/dsh.mjs +4 -2
- package/lib/integrations/native/claude.mjs +53 -9
- package/lib/integrations/native/codex.mjs +75 -15
- package/lib/integrations/native/dsh-acp.mjs +77 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +16 -3
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +67 -28
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +7 -1
- package/lib/runtime/client.mjs +18 -1
- package/lib/runtime/native/client.mjs +12 -2
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +346 -52
- package/lib/runtime/native/store.mjs +75 -21
- package/lib/runtime/project-path.mjs +24 -5
- package/lib/runtime/projects.mjs +3 -0
- package/lib/runtime/state.mjs +61 -26
- package/lib/shared/file-lock.mjs +62 -17
- package/lib/shared/redact.mjs +2 -1
- package/lib/shared/socket-lock-worker.mjs +15 -6
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +2005 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +104 -33
- package/lib/web/codex-sessions.mjs +60 -11
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +22 -4
- package/lib/web/http-routes.mjs +237 -24
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +370 -21
- package/lib/web/project-contexts.mjs +235 -42
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +134 -82
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +76 -27
- package/lib/web/tmux-client-snapshot.mjs +85 -0
- package/lib/web/tmux-sessions.mjs +71 -7
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +81 -73
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +286 -8
- package/lib/web/ui-interactions.mjs +6 -4
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +115 -51
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1969
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +9 -2
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/helpers/gc-clock-observation.mjs +33 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +284 -25
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +655 -188
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +22 -7
- package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +152 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-native-retry-checks.mjs +177 -0
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +438 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
|
@@ -1,6 +1,92 @@
|
|
|
1
|
-
# Web
|
|
1
|
+
# Web 新建 Agent、项目文件与继续本地任务
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Web 可以直接新建 Codex、Claude 或 DeepSeek Harness 后台 Agent,也可以继续同一项目中已有的 native worker 或受管终端。
|
|
4
|
+
|
|
5
|
+
## 浏览文件与产物
|
|
6
|
+
|
|
7
|
+
选择项目后,点击顶部“文件”,即可浏览项目目录并打开已保存的文件,无需先创建 Agent。
|
|
8
|
+
支持逐层进入目录、返回上级、输入项目相对目录和刷新。会话中“引用与执行器能力”的文件
|
|
9
|
+
检索结果也可以直接打开预览;“引用文件”仍然只在消息草稿里加入路径标记。
|
|
10
|
+
|
|
11
|
+
| 文件类型 | 预览方式 |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| 文本、源代码 | 显示转义后的原文 |
|
|
14
|
+
| Markdown | 显示基本排版与代码块,可切回源码 |
|
|
15
|
+
| HTML | 隔离的静态预览,可切回源码;脚本、表单、导航和外部资源不运行 |
|
|
16
|
+
| PNG、JPEG、GIF、WebP | 图片预览 |
|
|
17
|
+
| PDF | 浏览器内置查看器;未支持内嵌显示时可下载查看 |
|
|
18
|
+
| SVG | 仅显示源码 |
|
|
19
|
+
| 其他二进制文件 | 提示暂不支持预览,不尝试按文本显示 |
|
|
20
|
+
|
|
21
|
+
预览只读取当前项目内的普通文件,不修改文件,也不会把内容上传给模型。
|
|
22
|
+
`dist`、`build` 等产物目录可以浏览;运行状态、依赖缓存及已知凭据路径不提供预览,
|
|
23
|
+
符号链接和硬链接也不读取。目录每次最多显示 500 项,并提示未完整列出的情况;
|
|
24
|
+
文本超过 1 MiB 时只显示前部并标记截断,截断 HTML 只显示源码。图片和 PDF 最大为
|
|
25
|
+
10 MiB,超过限制会明确提示。文件在读取期间变化时,请刷新后重试。
|
|
26
|
+
|
|
27
|
+
切换文件、切换项目或关闭预览会使旧请求失效。手机可在目录与预览之间返回切换;
|
|
28
|
+
分屏辅助窗口的文件入口会在同一项目的主窗口中打开。静态预览不会启动生成网页所需的
|
|
29
|
+
开发服务器。
|
|
30
|
+
|
|
31
|
+
## 上传与编辑项目文件
|
|
32
|
+
|
|
33
|
+
在“文件”中进入目标目录,选择一个本机文件并核对目标文件名,再点击上传。文件会保存到
|
|
34
|
+
运行 HCC 的机器上当前项目的这个目录,单文件最大 10 MiB;不自动创建目录,不覆盖同名
|
|
35
|
+
文件。上传不是聊天附件发送,不会自动让 Agent 读取文件。要在任务中引用文件,仍需在
|
|
36
|
+
消息中明确引用项目路径。
|
|
37
|
+
|
|
38
|
+
完整的 UTF-8 文本、源码、Markdown 和 HTML 可以切换到编辑模式,修改后显式保存。
|
|
39
|
+
编辑上限为 1 MiB,截断预览和二进制文件不能在线编辑。UTF-8 BOM 与原有 CRLF 换行会
|
|
40
|
+
保留;混合换行文件会提示保存时统一为 LF。没有编辑过的文本不能重复保存。
|
|
41
|
+
保存前核对读取时的文件版本;
|
|
42
|
+
另一窗口或本地程序已修改文件时,保留草稿并显示冲突,核对磁盘内容后再决定如何处理。
|
|
43
|
+
HCC 的同文件写入会串行化;本地编辑器等其他程序不共享这个锁,因此这不是对任意外部
|
|
44
|
+
写入的系统级原子版本交换。保存通过同目录临时文件替换,避免向读者暴露半写的文本。
|
|
45
|
+
|
|
46
|
+
切换文件、项目或关闭文件窗口会保留当前页面内的编辑草稿;刷新或关闭整个页面仍可能
|
|
47
|
+
丢失未保存内容。“返回预览”保留草稿,“放弃草稿并重载”会先确认再丢弃。
|
|
48
|
+
遇到断线或写入回执不确定时,使用“读取已保存文件”核对文本,或“核对目标文件”比较
|
|
49
|
+
上传文件的大小和内容哈希;不会自动重发保存或上传。上传回执恢复只在浏览器保存项目
|
|
50
|
+
身份、路径、大小和哈希,不保存上传文件内容。
|
|
51
|
+
|
|
52
|
+
上传、编辑与预览沿用相同的项目路径限制,不允许修改运行状态、依赖缓存、已知凭据路径、
|
|
53
|
+
符号链接或硬链接。写入固定到经验证的目标父目录身份,不会跟随被替换的项目路径去写
|
|
54
|
+
另一个目录;如果原目录在写入中被移动,需重新选择当前位置并核对写入结果。
|
|
55
|
+
|
|
56
|
+
## 项目 Agent 启动默认值
|
|
57
|
+
|
|
58
|
+
顶部“设置”中可保存当前项目的默认 Agent,以及 Codex、Claude、DeepSeek Harness
|
|
59
|
+
各自的默认模型和工作目录。它们保存在所选项目的 HCC 数据库中,可供其他浏览器读取;
|
|
60
|
+
语言、主题、密度和字号仍是当前浏览器的显示偏好。
|
|
61
|
+
|
|
62
|
+
模型留空表示使用 provider 自身的当前配置;HCC 不会推测该配置中的模型,也不在保存时
|
|
63
|
+
调用模型验证名称。工作目录保存为项目相对路径,`.` 表示项目根目录,其他值必须指向
|
|
64
|
+
现有的项目内目录。新建时会再次检查目录,已经删除或移到项目外的目录不能用来启动。
|
|
65
|
+
|
|
66
|
+
保存后,“新建 Agent”会预填相应 provider 的模型和目录,本次创建仍可修改。取消后再打开
|
|
67
|
+
会保留尚未提交的草稿;默认值读取或重试不会覆盖已编辑的字段。成功创建后,下一次新建
|
|
68
|
+
重新读取项目默认值。清空模型表示本次使用 provider 默认配置,可以覆盖已保存的项目模型。
|
|
69
|
+
新值只用于后续 Web 新建 native worker;既有 worker、历史恢复、CLI 启动及高级终端/
|
|
70
|
+
App Server 流程各自沿用原有配置。此处不管理 provider 登录、API key、端点、环境变量
|
|
71
|
+
或全局配置文件。
|
|
72
|
+
|
|
73
|
+
保存使用版本校验。其他窗口先保存后,当前窗口会显示冲突并保留输入,需重新载入并
|
|
74
|
+
核对最新值;不会静默覆盖其他窗口的修改。“恢复初始值”先重置表单,保存后才生效。
|
|
75
|
+
如果保存响应因断线而不确定,先重新读取配置核对结果。
|
|
76
|
+
|
|
77
|
+
## 新建后台 Agent
|
|
78
|
+
|
|
79
|
+
运行 `hcc web`,选择项目,再点击“新建 Agent”。选择 Codex、Claude 或 DeepSeek Harness 后,默认使用“后台 Agent”;无需预先运行 `hcc native start`。对应 CLI 或 Claude SDK 仍需已安装,身份认证沿用运行 hello-cc 的环境,Web 不代为安装或登录。
|
|
80
|
+
|
|
81
|
+
工作目录使用该项目为此 provider 保存的默认值,未设置时为项目根目录,可改为项目内的子目录;worker 仍共享所选项目的任务、消息、锁和交接。需要在另一个项目工作时,先切换或添加该项目。模型预填项目中保存的默认值,也可在本次创建时修改或清空;留空使用 provider 当前配置,填写时应使用该 provider 支持的模型值。名称可留空自动生成。
|
|
82
|
+
|
|
83
|
+
“新建 Agent”用于创建新的 native worker;恢复已关闭的 worker,请使用项目栏“历史”中的“HCC 保留历史”。既有 `hcc native start` 和 `--resume last` CLI 继续可用。创建后在同一 Web 会话内发送提示、查看回执和处理审批;创建成功不代表已经运行模型任务。关闭页面或停止 Web runtime 都不会停止独立的 native runtime/worker,需要明确执行关闭操作。
|
|
84
|
+
|
|
85
|
+
“高级选项”保留终端 CLI、Codex 的结构化 App Server 及它们原有的恢复流程。Shell 使用终端,DeepSeek Harness 使用后台 Agent。以下交接说明分别适用于这些执行方式。
|
|
86
|
+
|
|
87
|
+
## 继续已有终端
|
|
88
|
+
|
|
89
|
+
CLI/tmux 适合继续已经托管的本地 Codex/Claude/shell:Web 打开同一个 pane,不另起执行器。
|
|
4
90
|
|
|
5
91
|
运行 `hcc web` 后选择已有会话。选中项和未发送草稿按项目保存于当前浏览器;刷新会恢复原位置。第一个 Web 连接取得控制,其他窗口可观察,点击“接管浏览器控制”后才可写入。服务端检查控制 epoch,旧窗口不能继续输入、改变终端尺寸、修改该 peer 或处理审批。本地 tmux client 仍可直接输入,Web 会显示其连接状态。
|
|
6
92
|
|
|
@@ -10,10 +96,12 @@
|
|
|
10
96
|
|
|
11
97
|
## Codex 结构化界面
|
|
12
98
|
|
|
13
|
-
|
|
99
|
+
在“新建 Agent”中选择 Codex,展开“高级选项”并选择结构化 App Server。需要本地已安装且支持 App Server 的 `codex`,登录和模型环境来自启动 hello-cc 的进程。hello-cc 不自动登录、不更改信任配置、不探测未公开的 TUI 内部 endpoint。
|
|
14
100
|
|
|
15
101
|
可先运行 `hcc doctor --codex --json` 查看只读诊断。它分别报告已安装版本、CLI help 声明的 App Server 启动参数、hooks 配置与项目中的 hook 调用记录;这些事实不等于完整 RPC 能力、hooks 已受信任、stdout 投递成功或模型已接收。版本/help 探测使用独立临时 HOME/CODEX_HOME 并清理,防止 Codex 启动文件触碰用户目录;hooks 配置仍从原路径只读检查。没有充分回执的状态显示 `unknown`,不会自动触发 hook 或修改 trust。普通 `hcc doctor` 仍只检查数据库。
|
|
16
102
|
|
|
103
|
+
升级 Codex 前可运行 `npm run codex:protocol -- --codex-bin /absolute/path/to/codex --out /tmp/hcc-protocol-new`。输出目录必须不存在;工具使用隔离 HOME/CODEX_HOME 生成官方 TypeScript 和 JSON Schema,记录版本/文件指纹并检查交接所依赖的协议子集。不兼容时返回非零退出码,未接入请求单独列出;生成成功不代表真实模型或外部授权验收。安装包内也可直接运行 `node /absolute/path/to/hello-cc/scripts/codex-protocol.mjs`。详见 `docs/codex-protocol.md`。
|
|
104
|
+
|
|
17
105
|
结构化界面提供消息、当前 turn 状态、追加指令、轮次中断、计划、差异和人工交互。命令/文件审批以及空表单 MCP 工具确认由当前控制窗口明确批准本次或拒绝。文件与网络权限可逐项勾选,并明确选择本轮或会话有效期;只能授予原请求的子集。问题支持选项、其他/自由输入和敏感答案,需逐题明确填写。接管控制本身不会批准工具。
|
|
18
106
|
|
|
19
107
|
表单输入与焦点会跨状态刷新保留,直接答案不写入 HCC 状态、事件、回执或浏览器存储。原 turn 完成、中断成功、provider 解决请求或执行器关闭后,请求卡片失效;旧控制窗口不能用过期 epoch 应答。参数被截断时只能拒绝或取消。MCP 支持下述标准表单和带原 thread/turn 标识的 URL 请求;嵌套对象、任意数组等复杂结构、动态工具及账号 token 刷新尚未接入。
|
|
@@ -36,7 +124,7 @@ MCP 标准表单可以直接在 Web 填写文本、数值、是/否和单选/多
|
|
|
36
124
|
|
|
37
125
|
## 本地 native worker 接手
|
|
38
126
|
|
|
39
|
-
|
|
127
|
+
已有 worker 可以继续从 CLI 启动,例如 `hcc native start --peer codex-worker --provider codex`,再打开 Web 的同一项目;也可直接使用上方的新建入口。Web 自动发现实际仍由 native runtime 持有的 worker,显示其原 peer/session、事件和投递回执;接手已有 worker 后的发送、中断和明确关闭都通过原 runtime 执行,不另建 provider 子进程。Claude/dsh worker 也走这条路径,按实际能力显示中断操作。
|
|
40
128
|
|
|
41
129
|
发送成功先显示队列回执;执行器接收、运行和完成分别使用实际投递状态。断线或不确定结果保留 submission ID,刷新不会重放消息。native runtime 重启或 worker 被替换时,旧 Web 视图及其控制凭证失效,重新发现当前 worker 后才能操作。
|
|
42
130
|
|
|
@@ -46,14 +134,28 @@ MCP 标准表单可以直接在 Web 填写文本、数值、是/否和单选/多
|
|
|
46
134
|
|
|
47
135
|
## 历史、协作工具与结果审阅
|
|
48
136
|
|
|
49
|
-
|
|
137
|
+
项目栏“历史”默认打开“HCC 保留历史”,可按 Codex、Claude、DeepSeek Harness 筛选当前项目的 native worker,查看已保留的 provider 事件与投递回执。native runtime 离线时仍可读取已保存记录;浏览历史不会启动 provider 或模型 turn。
|
|
138
|
+
|
|
139
|
+
worker 列表最多显示最近 100 项,超过时提示截断。事件每页 100 条,可以前后翻页;投递回执显示该 worker 最新 100 条。当前存储只保留整个项目最近约 2,000 条 provider 事件;这不是 provider 的完整对话档案。历史中可能没有最初输入、早期已裁剪事件或尚未被 HCC 记录的输出。DSH ACP 恢复本身也不代表原始对话重放。
|
|
140
|
+
|
|
141
|
+
恢复前先明确关闭原 worker,再选中历史项并确认。后端只接受已记录为 `closed`、原 peer/provider/session 与 owner 仍一致的记录;运行中、断连、失败或退出未确认的记录不能从此入口恢复。恢复沿用原 peer、provider 会话和工作目录,创建新的 native 执行器;不会重放已处理或投递状态不明的消息,尚未执行的队列消息会按现有 native 队列规则继续。provider 安装和账号要求仍然适用,DSH 还需实际 ACP runtime 声明 resume 能力。
|
|
142
|
+
|
|
143
|
+
恢复请求只提交一次。断线或响应丢失后,页面保留原恢复记录,通过刷新查找同一 peer 的新执行器;不会自动重新恢复。浏览器刷新后也可继续检查结果。恢复成功仅表示执行器已接入,Claude SDK 的会话初始化仍可能等到第一条新提示后才确认。
|
|
144
|
+
|
|
145
|
+
选择“Codex 已保存会话”,可分页浏览当前项目已保存的 thread,读取上下文,再显式恢复或分叉。恢复仍要求旧执行器已确认停止;分叉生成新的 thread/peer,原任务 owner 不变。有正在托管的源会话时,分叉也必须取得该会话的浏览器控制。历史读取不会启动模型 turn。
|
|
50
146
|
|
|
51
147
|
Web 专用 Codex App Server,以及由 HCC 托管的 Codex/Claude/dsh native worker,自动接入 [项目与 peer 受限的 MCP 协作工具](mcp.zh-CN.md)。配置只传给对应执行器或 worker,不写全局 provider 配置;关闭 worker 前撤销其临时权限。模型可查询任务与 inbox、领取任务、发送消息、写 handoff、获取/释放自己的资源锁和记录本地证据;不替用户执行发布或业务验收。
|
|
52
148
|
|
|
53
149
|
会话栏“结果审阅”汇总结构化 command 的执行状态与退出码、是否有 turn 差异、模型通过 MCP 明确提交的本地证据,以及人工添加的验收记录。自动结果仅在实时观察到新 turn 开始后归属当前任务;缺失该事件时,不会从历史快照补认任务证据。自动结果在项目数据库 events 中只保存中性标题、状态、退出码和来源 ID,不保存命令、输出或差异正文;正文仍可在可用的执行器会话或 provider 历史中查看。命令退出成功仅说明该命令成功;人工填写“通过”必须附证据引用。界面分别展示本地验证、发布部署、业务验收,并注明记录来源。turn 完成、测试记录或人工验收均不自动改变 hcc task 状态。`gc --history` 保留结果审计记录和最小提交去重凭据。
|
|
54
150
|
|
|
151
|
+
## Codex 账号与限额
|
|
152
|
+
|
|
153
|
+
Web 专用 Codex 和原生 Codex worker 新增只读“账号与限额”面板,观察窗口也可刷新。它显示本执行器的登录/供应商认证状态、可获取的订阅窗口、重置时间和读取时间;缺失或不支持的配额保持未知,账号切换与执行器替换拒绝旧查询结果。此操作不新建任务或 turn,也不提供登录、退出或 token 管理。未登录时需在运行任务的电脑完成本地 Codex 登录。使用方式、接口身份和真实/模拟验收边界见 [账号与限额说明](codex-account.zh-CN.md)。
|
|
154
|
+
|
|
55
155
|
## 验收边界
|
|
56
156
|
|
|
157
|
+
以下既有记录分别对应当时的执行器、交接和交互流程,不代表新增的统一“新建 Agent”、项目启动默认值、文件上传编辑、HCC 历史或 Web native 恢复入口已完成真实模型或发布验收。
|
|
158
|
+
|
|
57
159
|
本地验证包含真实 SQLite/HTTP、模拟交互请求、隔离 tmux 与桌面/手机浏览器。安装版 Codex 0.144.6 的协议验收另行覆盖 initialize、thread/start/list、10 个 MCP 工具发现和实际 `hcc_state` 读回;此协议检查不调用模型,可用 `node scripts/web-handoff-installed-acceptance.mjs` 重现。
|
|
58
160
|
|
|
59
161
|
真实模型验收已在隔离项目和 Codex home 中完成“本地 → Web → 本地”三段文件任务,全程保持原 Codex 进程和 thread;关闭 Web runtime 后仍可继续。可用 `scripts/web-native-model-acceptance.mjs` 在明确需要真实模型调用时重现。三种 provider 的真实生命周期、模型 MCP 发信、回复/ACK 和无回复循环另有独立回执。交互表单另有 11 项模拟 provider 浏览器检查。真实 Codex 已经完成“提问 → Web 答复 → 指定文件权限 → 本轮授权 → 写入所选答案 → 后续请求拒绝 → 本地继续”的验收;默认加载的 Claude SDK 已完成真实 Write 工具批准、拒绝和本地续接。dsh 已完成真实 ACP 写入的 Web 批准、拒绝和原 session 本地续接,审批卡片另通过桌面与 390px 手机检查;仅在隔离 profile 中对指定验收文件设置公开审批策略。完整结果与运行条件见 本地验收记录 (源码目录: `docs/verification/2026-10-02-web-local-task-handoff.md`)。
|
package/lib/cli/app.mjs
CHANGED
|
@@ -54,7 +54,8 @@ import { readPackageMeta } from '../package-meta.mjs';
|
|
|
54
54
|
import { removeGuidanceBlocks as removeGuidanceBlocksForRoot, writeGuidance as writeGuidanceForRoot } from '../guidance.mjs';
|
|
55
55
|
import { globalRuntimePath, runtimePath } from '../runtime/paths.mjs';
|
|
56
56
|
import { readRuntime, reclaimRuntimePointerFiles } from '../runtime/state.mjs';
|
|
57
|
-
import { waitForProcessIdentityExit } from '../process/identity.mjs';
|
|
57
|
+
import { waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity } from '../process/identity.mjs';
|
|
58
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
58
59
|
|
|
59
60
|
import { runtimeRequest } from '../runtime/client.mjs';
|
|
60
61
|
|
|
@@ -322,7 +323,7 @@ const { cmdDown, cmdFindRoot, cmdWhichReal } = createMiscCommands({
|
|
|
322
323
|
path, fs, process, CliError, parseOpts, printResult,
|
|
323
324
|
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
324
325
|
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
325
|
-
waitForProcessIdentityExit,
|
|
326
|
+
waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity, withFileLock,
|
|
326
327
|
PRODUCT_NAME
|
|
327
328
|
});
|
|
328
329
|
|
|
@@ -507,8 +508,9 @@ function webErrorStatus(err) {
|
|
|
507
508
|
if (!(err instanceof CliError)) return 500;
|
|
508
509
|
if (err.code === 'REGISTRY_BUSY') return 503;
|
|
509
510
|
if (err.code === 'CODEX_START_FAILED') return 503;
|
|
511
|
+
if (err.code === 'PROJECT_PATH_CHANGED') return 409;
|
|
510
512
|
if (err.code.startsWith('CODEX_') || ['STALE_CONTROL_EPOCH', 'UNKNOWN_CONTROL_CLIENT'].includes(err.code)) return 409;
|
|
511
|
-
if (['BAD_ARGS', 'BAD_REQUEST', 'PEER_IDENTITY_REQUIRED', 'REQUEST_TOO_LARGE'].includes(err.code)) return 400;
|
|
513
|
+
if (['BAD_ARGS', 'BAD_REQUEST', 'PEER_IDENTITY_REQUIRED', 'REQUEST_TOO_LARGE', 'INTERACTION_RESPONSE_INVALID'].includes(err.code)) return 400;
|
|
512
514
|
if (['PEER_IDENTITY_MISMATCH', 'TASK_OWNED', 'TASK_OWNER_MISMATCH', 'LOCK_OWNED', 'PROJECT_NOT_REGISTERED', 'PROJECT_PATH_FORBIDDEN', 'RUNTIME_ADMIN_REQUIRED'].includes(err.code)) return 403;
|
|
513
515
|
if (err.code.startsWith('NATIVE_')) return err.code === 'NATIVE_RUNTIME_OFFLINE' ? 503 : 409;
|
|
514
516
|
if (['NOT_FOUND'].includes(err.code)) return 404;
|
|
@@ -576,11 +578,12 @@ function formatHookEventName(hookType) {
|
|
|
576
578
|
}
|
|
577
579
|
|
|
578
580
|
function writeGuidance(ctx) {
|
|
579
|
-
return writeGuidanceForRoot(ctx.root);
|
|
581
|
+
return writeGuidanceForRoot(ctx.root, { expectedSnapshot: ctx.initialRootIdentity });
|
|
580
582
|
}
|
|
581
583
|
|
|
582
|
-
function removeGuidanceBlocks(ctx) {
|
|
583
|
-
return removeGuidanceBlocksForRoot(ctx.root
|
|
584
|
+
function removeGuidanceBlocks(ctx, options = {}) {
|
|
585
|
+
return removeGuidanceBlocksForRoot(ctx.root,
|
|
586
|
+
{ expectedSnapshot: ctx.initialRootIdentity, ...options });
|
|
584
587
|
}
|
|
585
588
|
|
|
586
589
|
function cmdMigrateState(ctx, args) {
|
|
@@ -163,8 +163,13 @@ export function createCoordinationCommands(deps) {
|
|
|
163
163
|
const kind = opts.kind || 'ask';
|
|
164
164
|
const runtime = opts.inject ? readRuntime(ctx) : null;
|
|
165
165
|
const db = connect(ctx);
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
let id;
|
|
167
|
+
try {
|
|
168
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
169
|
+
id = sendMessage(db, sender, recipient, taskId, kind, body);
|
|
170
|
+
} finally {
|
|
171
|
+
db.close();
|
|
172
|
+
}
|
|
168
173
|
let injected = false;
|
|
169
174
|
if (opts.inject) {
|
|
170
175
|
await injectPeer(ctx, recipient, body, !opts['no-enter'], runtime, sender);
|
package/lib/cli/commands/gc.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from '../../core/coordination/gc-plan.mjs';
|
|
26
26
|
import { runOptimisticEvidenceMutation } from '../../core/coordination/optimistic-evidence.mjs';
|
|
27
27
|
import { peerEvidenceAllowsReap } from '../../core/peers/evidence.mjs';
|
|
28
|
-
import {
|
|
28
|
+
import { globalRuntimePath } from '../../runtime/paths.mjs';
|
|
29
29
|
import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
|
|
30
30
|
import { readRuntime, reclaimRuntimePointerFiles } from '../../runtime/state.mjs';
|
|
31
31
|
import { runtimeRequest, runtimeBufferGcUnavailable } from '../../runtime/client.mjs';
|
|
@@ -58,6 +58,10 @@ export function createGcCommands(deps) {
|
|
|
58
58
|
// stale bufs) so automatic background gc bounds DB growth without deleting
|
|
59
59
|
// user-meaningful history.
|
|
60
60
|
// `db` is used open; the caller owns its lifecycle.
|
|
61
|
+
function bufferDirectory(ctx) {
|
|
62
|
+
return path.join(resolveProjectDatabase({ root: ctx.root, createStateDir: false }).stateDir, BUFS_DIR_NAME);
|
|
63
|
+
}
|
|
64
|
+
|
|
61
65
|
function captureGcClockSubject(db, {
|
|
62
66
|
bufferGcCutoffs,
|
|
63
67
|
olderThanDays,
|
|
@@ -628,10 +632,6 @@ function runGcWithHistorySnapshot(ctx, db, {
|
|
|
628
632
|
return results;
|
|
629
633
|
}
|
|
630
634
|
|
|
631
|
-
function bufferDirectory(ctx) {
|
|
632
|
-
return path.join(resolveProjectDatabase({ root: ctx.root, createStateDir: false }).stateDir, BUFS_DIR_NAME);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
635
|
function bufferDirectoryPaths(directory) {
|
|
636
636
|
try {
|
|
637
637
|
return fs.readdirSync(directory).map((name) => path.resolve(directory, name));
|
|
@@ -659,16 +659,16 @@ function localBufferEvidence(ctx, db, directory, unknownTracker = null, candidat
|
|
|
659
659
|
});
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
-
function runtimePointerPresent(
|
|
662
|
+
function runtimePointerPresent(localPointer) {
|
|
663
663
|
return Boolean(process.env.HCC_RUNTIME_URL ||
|
|
664
|
-
fs.existsSync(
|
|
664
|
+
fs.existsSync(localPointer) ||
|
|
665
665
|
fs.existsSync(globalRuntimePath()));
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
-
function reclaimUnavailableRuntimePointers(
|
|
668
|
+
function reclaimUnavailableRuntimePointers(localPointer, { dryRun = false } = {}) {
|
|
669
669
|
if (process.env.HCC_RUNTIME_URL) return { allowed: false, gcCutoffs: [] };
|
|
670
670
|
const gcCutoffs = [];
|
|
671
|
-
const result = reclaimRuntimePointerFiles([
|
|
671
|
+
const result = reclaimRuntimePointerFiles([localPointer, globalRuntimePath()], {
|
|
672
672
|
dryRun,
|
|
673
673
|
onReclaim: ({ gcCutoff }) => {
|
|
674
674
|
if (Number.isSafeInteger(gcCutoff)) gcCutoffs.push(gcCutoff);
|
|
@@ -718,15 +718,17 @@ function deferredPreparedBufferGc(prepared, graceActive = false) {
|
|
|
718
718
|
}
|
|
719
719
|
|
|
720
720
|
async function prepareManualBufferGc(ctx, db, { retentionSec, dryRun }) {
|
|
721
|
-
const
|
|
721
|
+
const stateDir = resolveProjectDatabase({ root: ctx.root, createStateDir: false }).stateDir;
|
|
722
|
+
const directory = path.join(stateDir, BUFS_DIR_NAME);
|
|
723
|
+
const localPointer = path.join(stateDir, 'runtime.json');
|
|
722
724
|
const unknownTracker = new Map();
|
|
723
725
|
const pointerGcCutoffs = [];
|
|
724
726
|
const recoverRuntimePointers = () => {
|
|
725
|
-
const recovery = reclaimUnavailableRuntimePointers(
|
|
727
|
+
const recovery = reclaimUnavailableRuntimePointers(localPointer, { dryRun });
|
|
726
728
|
pointerGcCutoffs.push(...recovery.gcCutoffs);
|
|
727
729
|
return recovery.allowed;
|
|
728
730
|
};
|
|
729
|
-
const pointerPresent = runtimePointerPresent(
|
|
731
|
+
const pointerPresent = runtimePointerPresent(localPointer);
|
|
730
732
|
let runtime = null;
|
|
731
733
|
try { runtime = readRuntime(ctx); } catch (error) {
|
|
732
734
|
if (pointerPresent && !recoverRuntimePointers()) {
|
|
@@ -5,6 +5,7 @@ import { parseOpts, validateOpts } from '../../cli-args.mjs';
|
|
|
5
5
|
import { tx } from '../../db/schema.mjs';
|
|
6
6
|
import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
|
|
7
7
|
import { projectDbPath } from '../../runtime/paths.mjs';
|
|
8
|
+
import { HOOK_ROOT_IDENTITY_ENV, assertHookRootIdentity } from '../../core/sessions/hook-root-identity.mjs';
|
|
8
9
|
|
|
9
10
|
export function createHookCommand(deps) {
|
|
10
11
|
const {
|
|
@@ -86,11 +87,22 @@ async function cmdHook(ctx, args) {
|
|
|
86
87
|
const hccRoot = (nativeOwner || !isDsh) && process.env.HCC_ROOT
|
|
87
88
|
? path.resolve(process.env.HCC_ROOT)
|
|
88
89
|
: path.resolve(hookCwd);
|
|
90
|
+
// First-party launches carry the original directory inode. Reject a stale
|
|
91
|
+
// callback before database resolution or project activity can target a new
|
|
92
|
+
// directory installed at the old pathname. dsh service hooks are intentionally
|
|
93
|
+
// payload-scoped and do not inherit a service-wide project identity.
|
|
94
|
+
const launchRootIdentity = (!isDsh || nativeOwner)
|
|
95
|
+
? assertHookRootIdentity(hccRoot, process.env[HOOK_ROOT_IDENTITY_ENV]) : null;
|
|
89
96
|
|
|
90
97
|
const dshProject = isDsh && !nativeOwner ? resolveProjectDatabase({ root: hccRoot, createStateDir: false }) : null;
|
|
91
98
|
const nativeProject = nativeOwner ? resolveProjectDatabase({ root: hccRoot,
|
|
92
99
|
db: process.env.HCC_DB, createStateDir: false }) : null;
|
|
93
|
-
const
|
|
100
|
+
const sameHookRoot = () => {
|
|
101
|
+
if (!ctx.root) return false;
|
|
102
|
+
try { return fs.realpathSync(ctx.root) === fs.realpathSync(hccRoot); }
|
|
103
|
+
catch { return path.resolve(ctx.root) === hccRoot; }
|
|
104
|
+
};
|
|
105
|
+
const ordinaryDb = (sameHookRoot() ? ctx.dbPath : null) ||
|
|
94
106
|
process.env.HCC_DB;
|
|
95
107
|
if (nativeProject && !fs.existsSync(nativeProject.db)) {
|
|
96
108
|
throw new CliError('NATIVE_HOOK_OWNERSHIP_MISMATCH', 'Native hook database must already be owned by its runtime');
|
|
@@ -100,9 +112,9 @@ async function cmdHook(ctx, args) {
|
|
|
100
112
|
const hookCtx = {
|
|
101
113
|
...ctx,
|
|
102
114
|
root: nativeProject?.root || dshProject?.root || hccRoot,
|
|
103
|
-
dbPath: nativeProject?.db || dshProject?.db || ordinaryDefaultDb || path.resolve(ordinaryDb)
|
|
115
|
+
dbPath: nativeProject?.db || dshProject?.db || ordinaryDefaultDb || path.resolve(ordinaryDb),
|
|
116
|
+
...(launchRootIdentity ? { initialRootIdentity: launchRootIdentity } : {})
|
|
104
117
|
};
|
|
105
|
-
if (!nativeOwner) registerProjectActivity(hookCtx);
|
|
106
118
|
|
|
107
119
|
// dsh uses only its true session ID. Ordinary hooks retain environment,
|
|
108
120
|
// resume, session, and terminal identity precedence.
|
|
@@ -123,6 +135,7 @@ async function cmdHook(ctx, args) {
|
|
|
123
135
|
// Hook delivery must not migrate another workspace's registered database.
|
|
124
136
|
const db = isDsh || nativeOwner ? connect(hookCtx, { migrateRegistered: false }) : connect(hookCtx);
|
|
125
137
|
try {
|
|
138
|
+
if (!nativeOwner) registerProjectActivity(hookCtx);
|
|
126
139
|
if (nativeOwner) {
|
|
127
140
|
// Native delivery owns context injection and completion receipts. Hooks
|
|
128
141
|
// only renew its verified owner, never turn a native binding into a
|
|
@@ -5,17 +5,19 @@ import { secureExistingProjectStateDirectory } from '../../runtime/project-path.
|
|
|
5
5
|
import { legacyProjectStateDir, projectStateDir } from '../../runtime/paths.mjs';
|
|
6
6
|
import { privateProjectStateDir, inspectPrivateProjectAuthority,
|
|
7
7
|
purgePrivateProjectState } from '../../runtime/private-state.mjs';
|
|
8
|
+
import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
|
|
9
|
+
import { purgePinnedLegacyProjectState } from '../../process/pinned-project-state.mjs';
|
|
8
10
|
|
|
9
11
|
export function createInstallCommands(deps) {
|
|
10
12
|
const {
|
|
11
13
|
path, fs, process, CliError, parseOpts, validateOpts, wantsHelp,
|
|
12
14
|
printResult, commandPath, connect, readRuntime, runtimeRequest,
|
|
13
|
-
|
|
15
|
+
globalRuntimePath, PRODUCT_NAME, VERSION, NPM_PACKAGE_NAME,
|
|
14
16
|
helpInstallHooks, helpShim, helpUninstall, helpUpdate,
|
|
15
17
|
loadSetup, shellCommand,
|
|
16
18
|
|
|
17
19
|
|
|
18
|
-
sameResolvedPath, writeGuidance, removeGuidanceBlocks,
|
|
20
|
+
sameResolvedPath, writeGuidance, removeGuidanceBlocks, spawnPinnedStateProcess,
|
|
19
21
|
|
|
20
22
|
} = deps;
|
|
21
23
|
|
|
@@ -240,13 +242,23 @@ async function cmdUninstall(ctx, args) {
|
|
|
240
242
|
if (opts.purge && !opts.yes) {
|
|
241
243
|
throw new CliError('CONFIRM_REQUIRED', 'Refusing to purge project data without --yes');
|
|
242
244
|
}
|
|
245
|
+
const selectedRoot = opts.purge ? captureSelectedCwdIdentity(ctx.root) : null;
|
|
246
|
+
let selectedLegacyState = null;
|
|
247
|
+
try {
|
|
243
248
|
// Resolve before changing hooks, guidance, or runtime state. An unsafe root
|
|
244
249
|
// with unmigrated legacy data must not be treated as an empty private store.
|
|
245
250
|
let purgeStateDir = null;
|
|
251
|
+
let purgeIsLegacy = false;
|
|
246
252
|
let missingPrivateState = false;
|
|
247
253
|
if (opts.purge) {
|
|
248
254
|
try {
|
|
249
|
-
|
|
255
|
+
const resolved = secureExistingProjectStateDirectory(ctx.root);
|
|
256
|
+
purgeStateDir = resolved.stateDir;
|
|
257
|
+
purgeIsLegacy = path.resolve(purgeStateDir) === path.join(selectedRoot.canonical, '.hello-cc');
|
|
258
|
+
if (purgeIsLegacy && resolved.exists) {
|
|
259
|
+
selectedLegacyState = captureSelectedCwdIdentity(purgeStateDir);
|
|
260
|
+
selectedRoot.assertUnchanged();
|
|
261
|
+
}
|
|
250
262
|
} catch (error) {
|
|
251
263
|
if (projectStateDir(ctx.root) !== privateProjectStateDir(ctx.root)) throw error;
|
|
252
264
|
const recoverablePending = error?.code === 'STATE_MIGRATION_REQUIRED' &&
|
|
@@ -261,7 +273,12 @@ async function cmdUninstall(ctx, args) {
|
|
|
261
273
|
const lines = [];
|
|
262
274
|
|
|
263
275
|
let runtime = null;
|
|
276
|
+
let runtimePointer = null;
|
|
264
277
|
if (!missingPrivateState) {
|
|
278
|
+
// Keep the validated state route fixed across the asynchronous stop probe.
|
|
279
|
+
// Re-deriving it afterwards could remove another project's pointer if a
|
|
280
|
+
// user-supplied root alias is retargeted in the meantime.
|
|
281
|
+
runtimePointer = path.join(secureExistingProjectStateDirectory(ctx.root).stateDir, 'runtime.json');
|
|
265
282
|
try {
|
|
266
283
|
runtime = readRuntime(ctx);
|
|
267
284
|
} catch (err) {
|
|
@@ -274,7 +291,7 @@ async function cmdUninstall(ctx, args) {
|
|
|
274
291
|
lines.push('runtime stopped');
|
|
275
292
|
} catch (err) {
|
|
276
293
|
if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
|
|
277
|
-
try { fs.rmSync(
|
|
294
|
+
try { fs.rmSync(runtimePointer, { force: true }); } catch {}
|
|
278
295
|
lines.push('stale runtime file removed');
|
|
279
296
|
}
|
|
280
297
|
} else {
|
|
@@ -282,6 +299,7 @@ async function cmdUninstall(ctx, args) {
|
|
|
282
299
|
}
|
|
283
300
|
|
|
284
301
|
const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims, uninstallPathEntry } = await loadSetup();
|
|
302
|
+
selectedRoot?.assertUnchanged();
|
|
285
303
|
const claude = uninstallClaudeHooks();
|
|
286
304
|
const codex = uninstallCodexHooks();
|
|
287
305
|
const shims = uninstallShims();
|
|
@@ -294,20 +312,26 @@ async function cmdUninstall(ctx, args) {
|
|
|
294
312
|
let guidance = [];
|
|
295
313
|
let purged = false;
|
|
296
314
|
if (opts.purge) {
|
|
297
|
-
guidance = removeGuidanceBlocks(ctx);
|
|
315
|
+
guidance = removeGuidanceBlocks(ctx, { expectedBinding: selectedRoot });
|
|
298
316
|
try {
|
|
299
|
-
|
|
317
|
+
selectedRoot.assertUnchanged();
|
|
318
|
+
if (!purgeIsLegacy) {
|
|
300
319
|
// Revalidate and delete under the same transition lock used by init
|
|
301
320
|
// and migration. An interrupted deletion remains marked purging.
|
|
302
321
|
purgeStateDir = purgePrivateProjectState(ctx.root);
|
|
303
322
|
} else {
|
|
304
323
|
// Async shutdown and hook removal happened after the initial lookup.
|
|
305
324
|
// Refuse a stale legacy target if project-state routing changed.
|
|
306
|
-
const current = secureExistingProjectStateDirectory(ctx.root)
|
|
307
|
-
if (path.resolve(current) !== path.resolve(purgeStateDir)) {
|
|
325
|
+
const current = secureExistingProjectStateDirectory(ctx.root);
|
|
326
|
+
if (path.resolve(current.stateDir) !== path.resolve(purgeStateDir)) {
|
|
308
327
|
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state route changed during uninstall');
|
|
309
328
|
}
|
|
310
|
-
|
|
329
|
+
if (selectedLegacyState) {
|
|
330
|
+
purgePinnedLegacyProjectState(ctx.root, purgeStateDir, selectedRoot, selectedLegacyState,
|
|
331
|
+
{ spawnProcess: spawnPinnedStateProcess });
|
|
332
|
+
} else if (current.exists) {
|
|
333
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state appeared during uninstall');
|
|
334
|
+
}
|
|
311
335
|
}
|
|
312
336
|
purged = true;
|
|
313
337
|
lines.push(`project data removed: ${purgeStateDir}`);
|
|
@@ -327,6 +351,10 @@ async function cmdUninstall(ctx, args) {
|
|
|
327
351
|
}
|
|
328
352
|
|
|
329
353
|
printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, path_entry: pathEntry, purge: purged, guidance }, () => lines.join('\n'));
|
|
354
|
+
} finally {
|
|
355
|
+
selectedLegacyState?.release();
|
|
356
|
+
selectedRoot?.release();
|
|
357
|
+
}
|
|
330
358
|
}
|
|
331
359
|
|
|
332
360
|
// ─── hcc scan ────────────────────────────────────────────────────────────────
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Factory pattern with injected deps.
|
|
3
3
|
|
|
4
4
|
import { readTx } from '../../db/schema.mjs';
|
|
5
|
+
import { isPeerEvidenceCompatibilityProtected } from '../../core/peers/evidence.mjs';
|
|
5
6
|
import {
|
|
6
7
|
findRequestedLock,
|
|
7
8
|
upsertRequestedLock,
|
|
@@ -101,7 +102,8 @@ export function createLockCommands(deps) {
|
|
|
101
102
|
!(Number(row.expires_at) <= t && evidenceByOwner.get(row.owner)?.state === 'dead'))
|
|
102
103
|
: subject.locks.filter((row) =>
|
|
103
104
|
Number(row.expires_at) > t ||
|
|
104
|
-
evidenceByOwner.get(row.owner)?.state === 'live'
|
|
105
|
+
evidenceByOwner.get(row.owner)?.state === 'live' ||
|
|
106
|
+
isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(row.owner))
|
|
105
107
|
);
|
|
106
108
|
const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
|
|
107
109
|
if (conflict) {
|
|
@@ -1,17 +1,79 @@
|
|
|
1
1
|
// Small utility commands extracted from bin/hcc.mjs: down, find-root, which-real.
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
|
|
2
4
|
|
|
3
5
|
export function createMiscCommands(deps) {
|
|
4
6
|
const {
|
|
5
|
-
path, process, CliError, parseOpts, printResult,
|
|
7
|
+
path, fs, process, CliError, parseOpts, printResult,
|
|
6
8
|
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
7
9
|
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
8
|
-
waitForProcessIdentityExit,
|
|
10
|
+
waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity, withFileLock,
|
|
9
11
|
PRODUCT_NAME
|
|
10
12
|
} = deps;
|
|
11
13
|
|
|
14
|
+
function sameStopPointer(left, right) {
|
|
15
|
+
return left.raw === right.raw && ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs']
|
|
16
|
+
.every((key) => left.stat[key] === right.stat[key]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readStopPointer(file) {
|
|
20
|
+
const stat = fs.lstatSync(file);
|
|
21
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('invalid runtime pointer');
|
|
22
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
23
|
+
const current = fs.lstatSync(file);
|
|
24
|
+
if (current.isSymbolicLink() || !current.isFile() ||
|
|
25
|
+
!sameStopPointer({ raw, stat }, { raw, stat: current })) {
|
|
26
|
+
throw new Error('runtime pointer changed');
|
|
27
|
+
}
|
|
28
|
+
return { raw, stat };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stopOwnerEvidence(runtime, pointerFiles) {
|
|
32
|
+
try {
|
|
33
|
+
if (runtime.source === 'env') return null;
|
|
34
|
+
const source = pointerFiles.find((file) => path.resolve(file) === path.resolve(runtime.source || ''));
|
|
35
|
+
const url = new URL(runtime.base_url);
|
|
36
|
+
if (!source || !['http:', 'https:'].includes(url.protocol) ||
|
|
37
|
+
!['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) return null;
|
|
38
|
+
const owner = runtime.process_identity || runtime.processIdentity;
|
|
39
|
+
if (Object.hasOwn(runtime, 'process_identity') && Object.hasOwn(runtime, 'processIdentity')) return null;
|
|
40
|
+
if (owner?.pid !== Number(runtime.pid) || compareProcessIdentity(owner, owner) !== 'live') return null;
|
|
41
|
+
const observed = inspectProcessIdentity(owner.pid);
|
|
42
|
+
if (observed.state !== 'live' || compareProcessIdentity(owner, observed.identity) !== 'live' ||
|
|
43
|
+
owner.commandHash !== observed.identity.commandHash) return null;
|
|
44
|
+
const pointer = readStopPointer(source);
|
|
45
|
+
const recorded = JSON.parse(pointer.raw);
|
|
46
|
+
if (Object.hasOwn(recorded, 'process_identity') && Object.hasOwn(recorded, 'processIdentity')) return null;
|
|
47
|
+
const recordedOwner = recorded.process_identity || recorded.processIdentity;
|
|
48
|
+
if (Number(recorded.pid) !== owner.pid ||
|
|
49
|
+
compareProcessIdentity(owner, recordedOwner) !== 'live' || owner.commandHash !== recordedOwner.commandHash ||
|
|
50
|
+
['base_url', 'token', 'tls_ca_file'].some((key) => recorded[key] !== runtime[key])) return null;
|
|
51
|
+
return { source, owner, pointer };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
12
57
|
async function cmdDown(ctx, args) {
|
|
13
58
|
if (args[0] === '--help' || args[0] === '-h') return helpDown();
|
|
14
|
-
const
|
|
59
|
+
const stopStartedAt = performance.now();
|
|
60
|
+
const elapsedSince = (startedAt) => Math.max(0, Math.round(performance.now() - startedAt));
|
|
61
|
+
function stopFailure(error, phase, state = 'unknown', confirmationMs = null) {
|
|
62
|
+
// Retain the original error and transport context. Only fixed phase/state
|
|
63
|
+
// vocabulary and monotonic durations describe the local confirmation.
|
|
64
|
+
error.extra = {
|
|
65
|
+
...error.extra,
|
|
66
|
+
stopPhase: phase,
|
|
67
|
+
state: ['live', 'dead', 'unknown'].includes(state) ? state : 'unknown',
|
|
68
|
+
stopElapsedMs: elapsedSince(stopStartedAt),
|
|
69
|
+
...(confirmationMs === null ? {} : { confirmationMs })
|
|
70
|
+
};
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
const localPointer = ctx.root
|
|
74
|
+
? path.join(resolveProjectDatabase({ root: ctx.root, createStateDir: false }).stateDir, 'runtime.json')
|
|
75
|
+
: runtimePath(ctx);
|
|
76
|
+
const pointerFiles = [localPointer, globalRuntimePath()];
|
|
15
77
|
let runtime;
|
|
16
78
|
try {
|
|
17
79
|
runtime = readRuntime(ctx);
|
|
@@ -23,31 +85,61 @@ export function createMiscCommands(deps) {
|
|
|
23
85
|
`${PRODUCT_NAME} stale runtime pointer removed${result.pointers === 1 ? '' : 's'}`);
|
|
24
86
|
return;
|
|
25
87
|
}
|
|
88
|
+
const stopEvidence = stopOwnerEvidence(runtime, pointerFiles);
|
|
26
89
|
try {
|
|
27
90
|
await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, runtime);
|
|
28
91
|
} catch (err) {
|
|
29
92
|
if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
|
|
30
|
-
if (
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
93
|
+
if (!stopEvidence) throw stopFailure(err, 'evidence_unavailable');
|
|
94
|
+
const confirmationStartedAt = performance.now();
|
|
95
|
+
const stopped = await waitForProcessIdentityExit(stopEvidence.owner, {
|
|
96
|
+
timeoutMs: 5_000,
|
|
97
|
+
intervalMs: 25
|
|
98
|
+
});
|
|
99
|
+
const confirmationMs = elapsedSince(confirmationStartedAt);
|
|
100
|
+
if (stopped.state !== 'dead') throw stopFailure(err, 'exit_unconfirmed', stopped.state, confirmationMs);
|
|
101
|
+
let cleanup;
|
|
102
|
+
let cleanupPhase = 'pointer_cleanup_failed';
|
|
103
|
+
try {
|
|
104
|
+
cleanup = withFileLock(stopEvidence.source, (file) => {
|
|
105
|
+
let current;
|
|
106
|
+
try { current = readStopPointer(file); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code === 'ENOENT') return { reclaimed: 0, blocked: false };
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (!sameStopPointer(stopEvidence.pointer, current)) {
|
|
112
|
+
cleanupPhase = 'pointer_changed';
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
// Hold the pointer's existing lock across comparison and reclamation.
|
|
116
|
+
// A replacement owner must never be removed by this stop request.
|
|
117
|
+
return reclaimRuntimePointerFiles([file], {
|
|
118
|
+
reclaimUnknown: false,
|
|
119
|
+
withLock: (_file, operation) => operation(file)
|
|
120
|
+
});
|
|
121
|
+
}, { createParent: false });
|
|
122
|
+
} catch { throw stopFailure(err, cleanupPhase, 'dead', confirmationMs); }
|
|
123
|
+
if (cleanup.blocked || cleanup.reclaimed > 1) throw stopFailure(err, 'pointer_cleanup_failed', 'dead', confirmationMs);
|
|
124
|
+
printResult(ctx, { runtime: stopEvidence.source }, () => cleanup.reclaimed === 1
|
|
125
|
+
? `${PRODUCT_NAME} stale runtime pointer removed`
|
|
126
|
+
: `${PRODUCT_NAME} runtime stopped`);
|
|
37
127
|
return;
|
|
38
128
|
}
|
|
39
129
|
if (runtime.source !== 'env') {
|
|
40
130
|
const processIdentity = runtime.process_identity || runtime.processIdentity || runtime.pid;
|
|
131
|
+
const confirmationStartedAt = performance.now();
|
|
41
132
|
const stopped = await waitForProcessIdentityExit(processIdentity, {
|
|
42
133
|
timeoutMs: 5_000,
|
|
43
134
|
intervalMs: 25
|
|
44
135
|
});
|
|
136
|
+
const confirmationMs = elapsedSince(confirmationStartedAt);
|
|
45
137
|
if (stopped.state !== 'dead') {
|
|
46
|
-
throw new CliError(
|
|
138
|
+
throw stopFailure(new CliError(
|
|
47
139
|
'RUNTIME_STOP_TIMEOUT',
|
|
48
140
|
`Runtime accepted the stop request, but process ${runtime.pid || 'unknown'} did not exit within 5s.`,
|
|
49
|
-
{ pid: runtime.pid || null
|
|
50
|
-
);
|
|
141
|
+
{ pid: runtime.pid || null }
|
|
142
|
+
), 'exit_unconfirmed', stopped.state, confirmationMs);
|
|
51
143
|
}
|
|
52
144
|
}
|
|
53
145
|
printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
|