@logicseek/hello-cc 1.0.2 → 1.1.0-rc.1

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 (79) hide show
  1. package/CHANGELOG.md +111 -23
  2. package/README.md +10 -1
  3. package/README.zh-CN.md +8 -1
  4. package/docs/commands.md +28 -4
  5. package/docs/commands.zh-CN.md +20 -4
  6. package/docs/dsh.md +2 -2
  7. package/docs/dsh.zh-CN.md +3 -3
  8. package/docs/native.md +84 -10
  9. package/docs/native.zh-CN.md +66 -5
  10. package/docs/web-handoff.zh-CN.md +11 -3
  11. package/lib/cli/app.mjs +42 -1
  12. package/lib/cli/commands/coordination.mjs +7 -2
  13. package/lib/cli/commands/dsh.mjs +1 -1
  14. package/lib/cli/commands/gc.mjs +9 -4
  15. package/lib/cli/commands/hook.mjs +6 -1
  16. package/lib/cli/commands/install.mjs +48 -6
  17. package/lib/cli/commands/lifecycle.mjs +14 -3
  18. package/lib/cli/commands/native.mjs +16 -9
  19. package/lib/cli/commands/query.mjs +15 -10
  20. package/lib/cli/commands/run.mjs +7 -6
  21. package/lib/core/coordination/results.mjs +27 -10
  22. package/lib/db/connection.mjs +135 -10
  23. package/lib/db/schema.mjs +59 -0
  24. package/lib/discover.mjs +4 -4
  25. package/lib/guidance.mjs +3 -2
  26. package/lib/integrations/codex-interactions.mjs +13 -11
  27. package/lib/integrations/dsh-collaboration.mjs +6 -12
  28. package/lib/integrations/dsh-cordis.mjs +17 -13
  29. package/lib/integrations/dsh.mjs +12 -9
  30. package/lib/integrations/hooks.mjs +132 -40
  31. package/lib/integrations/mcp-elicitation.mjs +116 -0
  32. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  33. package/lib/integrations/native/interactions.mjs +2 -1
  34. package/lib/integrations/peers/identity.mjs +42 -7
  35. package/lib/process/identity.mjs +15 -14
  36. package/lib/runtime/buffer-evidence.mjs +2 -1
  37. package/lib/runtime/native/service.mjs +11 -5
  38. package/lib/runtime/native/store.mjs +70 -18
  39. package/lib/runtime/paths.mjs +17 -2
  40. package/lib/runtime/private-file.mjs +76 -0
  41. package/lib/runtime/private-state.mjs +546 -0
  42. package/lib/runtime/project-context.mjs +2 -1
  43. package/lib/runtime/project-path.mjs +148 -12
  44. package/lib/runtime/project-trust.mjs +79 -0
  45. package/lib/runtime/projects.mjs +6 -3
  46. package/lib/runtime/state-migration.mjs +697 -0
  47. package/lib/runtime/state.mjs +22 -11
  48. package/lib/shared/automatic-result-key.mjs +6 -0
  49. package/lib/shared/json-file.mjs +55 -6
  50. package/lib/ui/help.mjs +6 -4
  51. package/lib/web/codex-app-server.mjs +26 -5
  52. package/lib/web/codex-sessions.mjs +5 -3
  53. package/lib/web/cookie-auth.mjs +1 -1
  54. package/lib/web/external-sessions.mjs +54 -5
  55. package/lib/web/http-routes.mjs +13 -9
  56. package/lib/web/http.mjs +20 -2
  57. package/lib/web/native-sessions.mjs +14 -7
  58. package/lib/web/peer-actions.mjs +27 -27
  59. package/lib/web/project-contexts.mjs +2 -3
  60. package/lib/web/runtime-main.mjs +22 -5
  61. package/lib/web/runtime.mjs +18 -4
  62. package/lib/web/session-serialize.mjs +1 -1
  63. package/lib/web/startup.mjs +115 -22
  64. package/lib/web/task-results.mjs +20 -17
  65. package/lib/web/terminal-assets.mjs +18 -0
  66. package/lib/web/tls.mjs +84 -33
  67. package/lib/web/tmux-sessions.mjs +2 -2
  68. package/lib/web/tmux-stream.mjs +3 -4
  69. package/lib/web/ui-codex.mjs +161 -32
  70. package/lib/web/ui-interactions.mjs +137 -4
  71. package/lib/web/ui-native.mjs +31 -13
  72. package/lib/web/ui-template.mjs +10 -3
  73. package/package.json +1 -1
  74. package/scripts/mcp-form-live-fixture.mjs +81 -0
  75. package/scripts/native-installed-acceptance.mjs +413 -0
  76. package/scripts/regression.mjs +46 -29
  77. package/scripts/web-mcp-form-acceptance.mjs +119 -0
  78. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  79. package/scripts/web-native-interaction-acceptance.mjs +77 -10
@@ -16,7 +16,11 @@
16
16
 
17
17
  结构化界面提供消息、当前 turn 状态、追加指令、轮次中断、计划、差异和人工交互。命令/文件审批以及空表单 MCP 工具确认由当前控制窗口明确批准本次或拒绝。文件与网络权限可逐项勾选,并明确选择本轮或会话有效期;只能授予原请求的子集。问题支持选项、其他/自由输入和敏感答案,需逐题明确填写。接管控制本身不会批准工具。
18
18
 
19
- 表单输入与焦点会跨状态刷新保留,直接答案不写入 HCC 状态、事件、回执或浏览器存储。原 turn 完成、中断成功、provider 解决请求或执行器关闭后,请求卡片失效;旧控制窗口不能用过期 epoch 应答。参数被截断时只能拒绝或取消。任意 MCP 表单字段、URL 登录、动态工具及账号 token 刷新尚未接入。
19
+ 表单输入与焦点会跨状态刷新保留,直接答案不写入 HCC 状态、事件、回执或浏览器存储。原 turn 完成、中断成功、provider 解决请求或执行器关闭后,请求卡片失效;旧控制窗口不能用过期 epoch 应答。参数被截断时只能拒绝或取消。MCP 支持下述标准表单和带原 thread/turn 标识的 URL 请求;嵌套对象、任意数组等复杂结构、动态工具及账号 token 刷新尚未接入。
20
+
21
+ MCP URL 请求显示授权目标,当前控制窗口点击“打开授权页面”后,原执行器接受请求才跳转。弹窗被拦截时不提交请求;过期请求不会打开授权地址。仅允许 HTTPS 或 loopback HTTP,链接带用户名/密码、其他协议、请求标识缺失或参数截断时保留拒绝/取消入口。loopback 地址需在执行器所在电脑打开,其他设备无法通过 HCC 代理本地授权页。
22
+
23
+ 打开页面只表示同意发起外部流程,不表示认证成功。请在外部页面完成操作,再返回关注原任务结果;新页面不保留 opener/referrer。URL、设备码消息和元数据不进入 HCC 交互事件或浏览器存储,实时待处理请求仍可供控制窗口查看。账号登录、token 刷新和 OpenAI 用户验证扩展不包含在此能力内。
20
24
 
21
25
  运行中发消息使用 steer;空闲时开始新 turn。中断当前轮与终止执行器是不同操作。turn 完成不自动把 hcc task 标记为验收完成。
22
26
 
@@ -26,6 +30,10 @@
26
30
 
27
31
  关闭浏览器后 App Server 仍由 hello-cc runtime 管理;停止 runtime 会关闭它。新 runtime 不会自动重启 App Server 或重放未确认消息,可以从已保存 thread 显式恢复。
28
32
 
33
+ MCP 标准表单可以直接在 Web 填写文本、数值、是/否和单选/多选;两种执行器共用校验,答复仍回到同一请求。状态刷新保留页面内的填写内容,可选项不自动发送默认值;页面重载会清空答复。未支持的模式或结构会说明原因,保留拒绝/取消入口。实现和验收范围见 `docs/verification/2026-10-02-mcp-form-web-validation.md`。
34
+
35
+ 真实模型复现脚本为 `scripts/web-mcp-form-live-acceptance.mjs --run-live`:先由本地 Codex 实际发起 MCP 交互,再从桌面/手机 Web 提交,核对同一 native 执行器继续完成和交回本地;另覆盖 Web-owned App Server。环境变量与隔离/账号范围见 `docs/native.zh-CN.md` 的真实模型与浏览器验收说明。成功以该次回执为准,默认运行只打印帮助,不调用模型。
36
+
29
37
  ## 本地 native worker 接手
30
38
 
31
39
  先用 `hcc native start --peer codex-worker --provider codex` 启动后台任务,再打开 Web 的同一项目。Web 自动发现实际仍由 native runtime 持有的 worker,显示其原 peer/session、事件和投递回执;发送、中断和明确关闭都通过原 runtime 执行,不新建 provider 子进程。Claude/dsh worker 也走这条路径,按实际能力显示中断操作。
@@ -42,12 +50,12 @@
42
50
 
43
51
  Web 专用 Codex App Server,以及由 HCC 托管的 Codex/Claude/dsh native worker,自动接入 [项目与 peer 受限的 MCP 协作工具](mcp.zh-CN.md)。配置只传给对应执行器或 worker,不写全局 provider 配置;关闭 worker 前撤销其临时权限。模型可查询任务与 inbox、领取任务、发送消息、写 handoff、获取/释放自己的资源锁和记录本地证据;不替用户执行发布或业务验收。
44
52
 
45
- 会话栏“结果审阅”汇总结构化 command 的退出码与输出、turn 差异、模型通过 MCP 提交的本地证据,以及人工添加的验收记录。命令退出成功仅说明该命令成功;人工填写“通过”必须附证据引用。界面分别展示本地验证、发布部署、业务验收,并注明记录来源。turn 完成、测试记录或人工验收均不自动改变 hcc task 状态。记录保存于项目数据库 events 中,`gc --history` 保留结果审计记录和最小提交去重凭据。
53
+ 会话栏“结果审阅”汇总结构化 command 的执行状态与退出码、是否有 turn 差异、模型通过 MCP 明确提交的本地证据,以及人工添加的验收记录。自动结果仅在实时观察到新 turn 开始后归属当前任务;缺失该事件时,不会从历史快照补认任务证据。自动结果在项目数据库 events 中只保存中性标题、状态、退出码和来源 ID,不保存命令、输出或差异正文;正文仍可在可用的执行器会话或 provider 历史中查看。命令退出成功仅说明该命令成功;人工填写“通过”必须附证据引用。界面分别展示本地验证、发布部署、业务验收,并注明记录来源。turn 完成、测试记录或人工验收均不自动改变 hcc task 状态。`gc --history` 保留结果审计记录和最小提交去重凭据。
46
54
 
47
55
  ## 验收边界
48
56
 
49
57
  本地验证包含真实 SQLite/HTTP、模拟交互请求、隔离 tmux 与桌面/手机浏览器。安装版 Codex 0.144.6 的协议验收另行覆盖 initialize、thread/start/list、10 个 MCP 工具发现和实际 `hcc_state` 读回;此协议检查不调用模型,可用 `node scripts/web-handoff-installed-acceptance.mjs` 重现。
50
58
 
51
- 真实模型验收已在隔离项目和 Codex home 中完成“本地 → Web → 本地”三段文件任务,全程保持原 Codex 进程和 thread;关闭 Web runtime 后仍可继续。可用 `scripts/web-native-model-acceptance.mjs` 在明确需要真实模型调用时重现。三种 provider 的真实生命周期、模型 MCP 发信、回复/ACK 和无回复循环另有独立回执。交互表单另有 11 项模拟 provider 浏览器检查。真实 Codex 已经完成“提问 → Web 答复 → 指定文件权限 → 本轮授权 → 写入所选答案 → 后续请求拒绝 → 本地继续”的验收;默认加载的 Claude SDK 已完成真实 Write 工具批准、拒绝和本地续接。dsh 的真实权限弹窗尚无本轮浏览器验收。完整结果与运行条件见 本地验收记录 (源码目录: `docs/verification/2026-10-02-web-local-task-handoff.md`)。
59
+ 真实模型验收已在隔离项目和 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`)。
52
60
 
53
61
  任意未托管 TUI/普通终端的活动执行器迁移、私有 daemon endpoint、任意协议版本的完整兼容矩阵,以及发布安装和真实业务验收仍在本次证据范围之外。
package/lib/cli/app.mjs CHANGED
@@ -89,6 +89,9 @@ import { assignTeamWorkers, expandTeamWorkers, inferTeamItems } from '../core/co
89
89
  import { autoPeerBasis, autoPeerKind, autoPeerProviderSession, autoPeerResumeId, autoPeerSessionId, readAncestorCliInfo, resolveCurrentPeer, resumeIdFromArgs, shortHash } from '../integrations/peers/identity.mjs';
90
90
  import { inspectProviderProcess } from '../integrations/peers/processes.mjs';
91
91
  import { registerProjectActivity } from '../runtime/projects.mjs';
92
+ import { migrateLegacyProjectState } from '../runtime/state-migration.mjs';
93
+ import { projectDbPath } from '../runtime/paths.mjs';
94
+ import { ensureDshIntegration } from '../integrations/dsh.mjs';
92
95
 
93
96
  // Lazy-load lib modules (they may import node-pty which needs to be optional)
94
97
  const _libDir = path.resolve(fileURLToPath(import.meta.url), '..', '..');
@@ -303,7 +306,7 @@ const { cmdInit, cmdRegister, cmdEnv, cmdJoin } = createLifecycleCommands({
303
306
  connect, now, tx, addEvent, printResult, CliError, parseOpts, intOpt,
304
307
  registerProjectActivity, resolveCurrentPeer, resolveTargetPeer,
305
308
  upsertPeer, upsertCanonicalPeerBinding, storedPeerIdentity, detectBranch,
306
- writeGuidance, helpEnv, helpJoin, shellExports, path, process
309
+ readAncestorCliInfo, writeGuidance, helpEnv, helpJoin, shellExports, path, process
307
310
  });
308
311
 
309
312
  const { cmdTeam } = createTeamCommands({
@@ -580,6 +583,43 @@ function removeGuidanceBlocks(ctx) {
580
583
  return removeGuidanceBlocksForRoot(ctx.root);
581
584
  }
582
585
 
586
+ function cmdMigrateState(ctx, args) {
587
+ if (wantsHelp(args)) {
588
+ console.log(`${CLI_NAME} migrate-state --offline --yes\n\n` +
589
+ 'Stop all old hello-cc Web, Native, PTY, tmux, hook and external database writers first. ' +
590
+ 'This command verifies known process evidence, snapshots SQLite including WAL, and leaves the old directory untouched.');
591
+ return;
592
+ }
593
+ const opts = parseOpts(args, { booleans: ['offline', 'yes'] });
594
+ validateOpts('migrate-state', opts, ['offline', 'yes']);
595
+ if (opts.offline !== true || opts.yes !== true) {
596
+ throw new CliError('STATE_MIGRATION_OFFLINE_REQUIRED',
597
+ 'Stop every old project-state writer, then acknowledge downtime with migrate-state --offline --yes');
598
+ }
599
+ const result = migrateLegacyProjectState(ctx.root, {
600
+ confirmedOffline: true,
601
+ // The operator's explicit --offline assertion covers external writers
602
+ // that cannot be discovered reliably across supported platforms. The
603
+ // migrator still checks known runtime, PTY and SQLite peer evidence.
604
+ assertOffline: () => true
605
+ });
606
+ let dshRebuilt = false;
607
+ if (result.dsh) {
608
+ try {
609
+ ensureDshIntegration({ ...ctx, dbPath: projectDbPath(ctx.root) }, { mode: result.dsh.mode });
610
+ dshRebuilt = true;
611
+ } catch (error) {
612
+ throw new CliError('STATE_MIGRATION_DSH_REBUILD_REQUIRED',
613
+ `Project state migrated to ${result.stateDir}, but DSH configuration needs rebuilding. ` +
614
+ `Run ${result.dsh.rebuildCommand}. Original data remains at ${result.sourceDir}.`,
615
+ { cause: error });
616
+ }
617
+ }
618
+ printResult(ctx, { ...result, dsh_rebuilt: dshRebuilt },
619
+ () => `project state migrated: ${result.stateDir}\nlegacy source kept: ${result.sourceDir}` +
620
+ (dshRebuilt ? '\nDSH integration rebuilt for the new private path' : ''));
621
+ }
622
+
583
623
  // ─── hcc doctor ─────────────────────────────────────────────────────────────
584
624
  // Health self-check: SQLite integrity, schema version, WAL/DB size, row counts.
585
625
  async function dispatch(ctx, rest) {
@@ -592,6 +632,7 @@ async function dispatch(ctx, rest) {
592
632
  if (command === 'update') return cmdUpdate(ctx, args);
593
633
  if (command === 'uninstall') return cmdUninstall(ctx, args);
594
634
  if (command === 'init') return cmdInit(ctx, args);
635
+ if (command === 'migrate-state') return cmdMigrateState(ctx, args);
595
636
  if (command === 'register') return cmdRegister(ctx, args);
596
637
  if (command === 'join') return cmdJoin(ctx, args);
597
638
  if (command === 'env') return cmdEnv(ctx, args);
@@ -184,8 +184,13 @@ export function createCoordinationCommands(deps) {
184
184
  const kind = opts.kind || 'broadcast';
185
185
  const runtime = opts.inject ? readRuntime(ctx) : null;
186
186
  const db = connect(ctx);
187
- touchCurrentPeer(db, ctx, identity, null, 'shell');
188
- const id = sendMessage(db, sender, 'all', taskId, kind, body);
187
+ let id;
188
+ try {
189
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
190
+ id = sendMessage(db, sender, 'all', taskId, kind, body);
191
+ } finally {
192
+ db.close();
193
+ }
189
194
  let injected = 0;
190
195
  let skipped = 0;
191
196
  if (opts.inject) {
@@ -14,7 +14,7 @@ function defaultHelp() {
14
14
  ' hcc dsh status [--dsh-bin PATH]',
15
15
  ' hcc dsh web [--mode hooks|cordis|off] [--dsh-bin PATH] [--dsh-home PATH] -- [dsh web arguments]',
16
16
  '',
17
- 'Setup writes project-local configuration under .hello-cc/dsh/. Modes: hooks (default), cordis (native tools), off.',
17
+ 'Setup writes managed configuration under the selected private state directory. Modes: hooks (default), cordis (native tools), off.',
18
18
  'Web starts the official dsh web runtime; its real sessions join hello-cc through the selected integration.',
19
19
  'Pass runtime arguments after --, for example: hcc dsh web -- --port 8080',
20
20
  'Show dsh web help: hcc dsh web -- --help',
@@ -26,6 +26,7 @@ import {
26
26
  import { runOptimisticEvidenceMutation } from '../../core/coordination/optimistic-evidence.mjs';
27
27
  import { peerEvidenceAllowsReap } from '../../core/peers/evidence.mjs';
28
28
  import { runtimePath, globalRuntimePath } from '../../runtime/paths.mjs';
29
+ import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
29
30
  import { readRuntime, reclaimRuntimePointerFiles } from '../../runtime/state.mjs';
30
31
  import { runtimeRequest, runtimeBufferGcUnavailable } from '../../runtime/client.mjs';
31
32
  import { collectBufferEvidence } from '../../runtime/buffer-evidence.mjs';
@@ -263,12 +264,16 @@ function captureRequiredBufferDirectoryComponent(component, description) {
263
264
  function captureOptionalBufferDirectory(root, directoryName) {
264
265
  const requestedRoot = path.resolve(root);
265
266
  const canonicalRoot = fs.realpathSync.native(requestedRoot);
266
- const stateDirectory = path.join(canonicalRoot, '.hello-cc');
267
+ const stateDirectory = resolveProjectDatabase({ root: canonicalRoot, createStateDir: false }).stateDir;
267
268
  const directory = path.join(stateDirectory, directoryName);
268
269
  const components = [
269
- captureRequiredBufferDirectoryComponent(canonicalRoot, 'Project root'),
270
- captureRequiredBufferDirectoryComponent(stateDirectory, 'Project state directory')
270
+ captureRequiredBufferDirectoryComponent(canonicalRoot, 'Project root')
271
271
  ];
272
+ try {
273
+ components.push(captureRequiredBufferDirectoryComponent(stateDirectory, 'Project state directory'));
274
+ } catch (error) {
275
+ if (error?.code !== 'ENOENT') throw error;
276
+ }
272
277
 
273
278
  let stat;
274
279
  try {
@@ -624,7 +629,7 @@ function runGcWithHistorySnapshot(ctx, db, {
624
629
  }
625
630
 
626
631
  function bufferDirectory(ctx) {
627
- return path.resolve(path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME));
632
+ return path.join(resolveProjectDatabase({ root: ctx.root, createStateDir: false }).stateDir, BUFS_DIR_NAME);
628
633
  }
629
634
 
630
635
  function bufferDirectoryPaths(directory) {
@@ -4,6 +4,7 @@
4
4
  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
+ import { projectDbPath } from '../../runtime/paths.mjs';
7
8
 
8
9
  export function createHookCommand(deps) {
9
10
  const {
@@ -89,13 +90,17 @@ async function cmdHook(ctx, args) {
89
90
  const dshProject = isDsh && !nativeOwner ? resolveProjectDatabase({ root: hccRoot, createStateDir: false }) : null;
90
91
  const nativeProject = nativeOwner ? resolveProjectDatabase({ root: hccRoot,
91
92
  db: process.env.HCC_DB, createStateDir: false }) : null;
93
+ const ordinaryDb = (ctx.root && path.resolve(ctx.root) === hccRoot ? ctx.dbPath : null) ||
94
+ process.env.HCC_DB;
92
95
  if (nativeProject && !fs.existsSync(nativeProject.db)) {
93
96
  throw new CliError('NATIVE_HOOK_OWNERSHIP_MISMATCH', 'Native hook database must already be owned by its runtime');
94
97
  }
98
+ const ordinaryDefaultDb = !dshProject && !nativeProject && !ordinaryDb
99
+ ? projectDbPath(hccRoot) : null;
95
100
  const hookCtx = {
96
101
  ...ctx,
97
102
  root: nativeProject?.root || dshProject?.root || hccRoot,
98
- dbPath: nativeProject?.db || dshProject?.db || path.join(hccRoot, '.hello-cc', 'mesh.db')
103
+ dbPath: nativeProject?.db || dshProject?.db || ordinaryDefaultDb || path.resolve(ordinaryDb)
99
104
  };
100
105
  if (!nativeOwner) registerProjectActivity(hookCtx);
101
106
 
@@ -1,6 +1,10 @@
1
1
  // Install/setup/update/uninstall/shim commands extracted from bin/hcc.mjs.
2
2
 
3
3
  import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { secureExistingProjectStateDirectory } from '../../runtime/project-path.mjs';
5
+ import { legacyProjectStateDir, projectStateDir } from '../../runtime/paths.mjs';
6
+ import { privateProjectStateDir, inspectPrivateProjectAuthority,
7
+ purgePrivateProjectState } from '../../runtime/private-state.mjs';
4
8
 
5
9
  export function createInstallCommands(deps) {
6
10
  const {
@@ -236,14 +240,33 @@ async function cmdUninstall(ctx, args) {
236
240
  if (opts.purge && !opts.yes) {
237
241
  throw new CliError('CONFIRM_REQUIRED', 'Refusing to purge project data without --yes');
238
242
  }
243
+ // Resolve before changing hooks, guidance, or runtime state. An unsafe root
244
+ // with unmigrated legacy data must not be treated as an empty private store.
245
+ let purgeStateDir = null;
246
+ let missingPrivateState = false;
247
+ if (opts.purge) {
248
+ try {
249
+ purgeStateDir = secureExistingProjectStateDirectory(ctx.root).stateDir;
250
+ } catch (error) {
251
+ if (projectStateDir(ctx.root) !== privateProjectStateDir(ctx.root)) throw error;
252
+ const recoverablePending = error?.code === 'STATE_MIGRATION_REQUIRED' &&
253
+ inspectPrivateProjectAuthority(ctx.root) === 'pending';
254
+ if (!['STATE_AUTHORITY_MISSING', 'STATE_PURGE_INCOMPLETE'].includes(error?.code) &&
255
+ !recoverablePending) throw error;
256
+ purgeStateDir = privateProjectStateDir(ctx.root);
257
+ missingPrivateState = true;
258
+ }
259
+ }
239
260
 
240
261
  const lines = [];
241
262
 
242
263
  let runtime = null;
243
- try {
244
- runtime = readRuntime(ctx);
245
- } catch (err) {
246
- if (!(err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING')) throw err;
264
+ if (!missingPrivateState) {
265
+ try {
266
+ runtime = readRuntime(ctx);
267
+ } catch (err) {
268
+ if (!(err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING')) throw err;
269
+ }
247
270
  }
248
271
  if (runtime) {
249
272
  try {
@@ -273,9 +296,28 @@ async function cmdUninstall(ctx, args) {
273
296
  if (opts.purge) {
274
297
  guidance = removeGuidanceBlocks(ctx);
275
298
  try {
276
- fs.rmSync(path.join(ctx.root, '.hello-cc'), { recursive: true, force: true });
299
+ if (path.resolve(purgeStateDir) === path.resolve(privateProjectStateDir(ctx.root))) {
300
+ // Revalidate and delete under the same transition lock used by init
301
+ // and migration. An interrupted deletion remains marked purging.
302
+ purgeStateDir = purgePrivateProjectState(ctx.root);
303
+ } else {
304
+ // Async shutdown and hook removal happened after the initial lookup.
305
+ // Refuse a stale legacy target if project-state routing changed.
306
+ const current = secureExistingProjectStateDirectory(ctx.root).stateDir;
307
+ if (path.resolve(current) !== path.resolve(purgeStateDir)) {
308
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state route changed during uninstall');
309
+ }
310
+ fs.rmSync(purgeStateDir, { recursive: true, force: true });
311
+ }
277
312
  purged = true;
278
- lines.push(`project data removed: ${path.join(ctx.root, '.hello-cc')}`);
313
+ lines.push(`project data removed: ${purgeStateDir}`);
314
+ const legacyDir = legacyProjectStateDir(ctx.root);
315
+ if (path.resolve(legacyDir) !== path.resolve(purgeStateDir) && fs.existsSync(legacyDir)) {
316
+ lines.push(`legacy project data kept: ${legacyDir}`);
317
+ lines.push('private authority kept; future use requires an explicit offline migration of the legacy source');
318
+ } else if (path.resolve(legacyDir) !== path.resolve(purgeStateDir)) {
319
+ lines.push('private authority kept; a fresh init can now create new private state');
320
+ }
279
321
  } catch (err) {
280
322
  throw new CliError('PURGE_FAILED', `Could not remove .hello-cc: ${err.message}`);
281
323
  }
@@ -4,10 +4,21 @@ export function createLifecycleCommands(deps) {
4
4
  const {
5
5
  connect, now, addEvent, printResult, CliError, parseOpts, intOpt,
6
6
  registerProjectActivity, resolveCurrentPeer, resolveTargetPeer,
7
- upsertPeer, upsertCanonicalPeerBinding, detectBranch,
7
+ upsertPeer, upsertCanonicalPeerBinding, detectBranch, readAncestorCliInfo,
8
8
  writeGuidance, helpEnv, helpJoin, shellExports, path, process
9
9
  } = deps;
10
10
 
11
+ function registrationPid(opts) {
12
+ if (opts.pid !== undefined) return intOpt(opts, 'pid', process.ppid);
13
+ const ancestor = readAncestorCliInfo();
14
+ if (['claude', 'codex'].includes(ancestor?.kind) &&
15
+ (!opts.kind || opts.kind === ancestor.kind) &&
16
+ Number.isSafeInteger(ancestor.pid) && ancestor.pid > 1) {
17
+ return ancestor.pid;
18
+ }
19
+ return process.ppid;
20
+ }
21
+
11
22
  async function cmdInit(ctx, args) {
12
23
  registerProjectActivity(ctx);
13
24
  const opts = parseOpts(args, { booleans: ['no-guidance'] });
@@ -34,7 +45,7 @@ export function createLifecycleCommands(deps) {
34
45
  role: opts.role || '',
35
46
  worktree: path.resolve(opts.worktree || ctx.cwd),
36
47
  branch: opts.branch || detectBranch(ctx.cwd),
37
- pid: intOpt(opts, 'pid', process.ppid),
48
+ pid: registrationPid(opts),
38
49
  status: opts.status || 'idle',
39
50
  capabilities: Array.isArray(opts.cap) ? opts.cap.join(',') : (opts.cap || opts.capabilities || '')
40
51
  };
@@ -67,7 +78,7 @@ export function createLifecycleCommands(deps) {
67
78
  role: opts.role || 'peer',
68
79
  worktree: path.resolve(opts.worktree || ctx.cwd),
69
80
  branch: opts.branch || detectBranch(ctx.cwd),
70
- pid: intOpt(opts, 'pid', process.ppid),
81
+ pid: registrationPid(opts),
71
82
  status: opts.status || 'working',
72
83
  capabilities: Array.isArray(opts.cap) ? opts.cap.join(',') : (opts.cap || opts.capabilities || 'manual-shell')
73
84
  };
@@ -7,6 +7,7 @@ import { parseOpts, validateOpts, wantsHelp, intOpt } from '../../cli-args.mjs';
7
7
  import { printResult } from '../../format.mjs';
8
8
  import { commandPath } from '../../cli-runtime.mjs';
9
9
  import { nativePaths, readNativePointer } from '../../runtime/native/store.mjs';
10
+ import { openPrivateAppendFile } from '../../runtime/private-file.mjs';
10
11
  import { nativeRequest } from '../../runtime/native/client.mjs';
11
12
  import { startNativeService } from '../../runtime/native/service.mjs';
12
13
 
@@ -37,15 +38,21 @@ export function createNativeCommands(deps) {
37
38
  }
38
39
  }
39
40
  const paths = nativePaths(ctx, { create: true });
40
- const log = fs.openSync(paths.log, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o600);
41
- if (!fs.fstatSync(log).isFile() || fs.fstatSync(log).nlink !== 1) {
42
- fs.closeSync(log);
43
- throw new CliError('NATIVE_STATE_UNSAFE', 'Native runtime log must be a private regular file');
41
+ let log;
42
+ let child;
43
+ try {
44
+ log = openPrivateAppendFile(paths.log);
45
+ child = spawn(process.execPath, [commandPath(), '--root', fs.realpathSync(ctx.root), '--db', path.resolve(ctx.dbPath), 'native', 'serve'], {
46
+ cwd: ctx.root, env: process.env, detached: true, stdio: ['ignore', log, log]
47
+ });
48
+ } catch (error) {
49
+ if (error?.code === 'PROJECT_PATH_FORBIDDEN') {
50
+ throw new CliError('NATIVE_STATE_UNSAFE', error.message);
51
+ }
52
+ throw error;
53
+ } finally {
54
+ if (log !== undefined) fs.closeSync(log);
44
55
  }
45
- const child = spawn(process.execPath, [commandPath(), '--root', fs.realpathSync(ctx.root), '--db', path.resolve(ctx.dbPath), 'native', 'serve'], {
46
- cwd: ctx.root, env: process.env, detached: true, stdio: ['ignore', log, log]
47
- });
48
- fs.closeSync(log);
49
56
  let failed;
50
57
  child.on('error', (error) => { failed = error; });
51
58
  child.unref();
@@ -113,7 +120,7 @@ export function createNativeCommands(deps) {
113
120
  const data = fs.readFileSync(path.resolve(ctx.cwd, opts['response-file']), 'utf8');
114
121
  if (data.length > 100000) throw new CliError('BAD_ARGS', 'Response file exceeds the limit');
115
122
  try { response = JSON.parse(data); } catch { throw new CliError('BAD_ARGS', 'Response file must contain JSON'); }
116
- if (!response || typeof response !== 'object' || Array.isArray(response) || Object.keys(response).some(key => !['permissions','scope','answers'].includes(key))) throw new CliError('BAD_ARGS', 'Response file accepts permissions, scope and answers only');
123
+ if (!response || typeof response !== 'object' || Array.isArray(response) || Object.keys(response).some(key => !['permissions','scope','answers','content'].includes(key))) throw new CliError('BAD_ARGS', 'Response file accepts permissions, scope, answers and MCP content only');
117
124
  }
118
125
  const request = pending[0];
119
126
  result = await nativeRequest(ctx, 'POST', '/respond', { peer: opts.peer, generation: state.generation,
@@ -1,5 +1,7 @@
1
1
  // Coordination query commands extracted from bin/hcc.mjs: peers, status, state, prompt.
2
2
 
3
+ import { shellQuoteArg } from '../../format.mjs';
4
+
3
5
  export function createQueryCommands(deps) {
4
6
  const {
5
7
  connect, now, printResult, table, CliError, ACTIVE_PEER_TTL,
@@ -63,7 +65,10 @@ export function createQueryCommands(deps) {
63
65
  const peer = required(opts, 'peer', 'HCC_PEER');
64
66
  const kind = opts.kind || 'codex';
65
67
  const role = opts.role || 'peer';
66
- const cmd = `node ${commandPath()} --root ${JSON.stringify(ctx.root)}`;
68
+ const cmd = ['node', commandPath(), '--root', ctx.root, '--db', ctx.dbPath].map(shellQuoteArg).join(' ');
69
+ const quotedPeer = shellQuoteArg(peer);
70
+ const quotedKind = shellQuoteArg(kind);
71
+ const quotedRole = shellQuoteArg(role);
67
72
  const text = `You are ${peer}, a ${kind} ${role} session working in this project.
68
73
 
69
74
  Use hcc as the shared coordination bus for this terminal session. This
@@ -71,23 +76,23 @@ project uses a flat peer mesh: there is no required main/worker hierarchy.
71
76
 
72
77
  Run these commands before changing files:
73
78
 
74
- ${cmd} register --peer ${peer} --kind ${kind} --role ${role}
75
- ${cmd} state --peer ${peer}
76
- ${cmd} msg inbox --peer ${peer}
77
- ${cmd} task next --peer ${peer}
79
+ ${cmd} register --peer ${quotedPeer} --kind ${quotedKind} --role ${quotedRole}
80
+ ${cmd} state --peer ${quotedPeer}
81
+ ${cmd} msg inbox --peer ${quotedPeer}
82
+ ${cmd} task next --peer ${quotedPeer}
78
83
 
79
84
  Coordination rules:
80
85
  - Claim exactly one task before editing.
81
86
  - If state shows a current task for ${peer}, continue that task before claiming another pending task.
82
87
  - Before editing a file, directory, module, or shared test resource, run:
83
- ${cmd} lock acquire --peer ${peer} --resource <path-or-module> [--scope <scope>] --task <task-id>
88
+ ${cmd} lock acquire --peer ${quotedPeer} --resource <path-or-module> [--scope <scope>] --task <task-id>
84
89
  - If a lock is held by another peer, message that peer instead of editing:
85
- ${cmd} msg send --from ${peer} --to <peer-id> --body "<question>"
90
+ ${cmd} msg send --from ${quotedPeer} --to <peer-id> --body "<question>"
86
91
  - Report progress or requests through msg send.
87
92
  - Before stopping, run tests, create a handoff, and release locks:
88
- ${cmd} handoff create --from ${peer} --task <task-id> --summary "<what changed>" --tests "<commands/results>" --risks "<known risks>"
89
- ${cmd} task done --peer ${peer} --id <task-id> --summary "<done summary>"
90
- ${cmd} lock release --peer ${peer} --resource <path-or-module> [--scope <scope>]
93
+ ${cmd} handoff create --from ${quotedPeer} --task <task-id> --summary "<what changed>" --tests "<commands/results>" --risks "<known risks>"
94
+ ${cmd} task done --peer ${quotedPeer} --id <task-id> --summary "<done summary>"
95
+ ${cmd} lock release --peer ${quotedPeer} --resource <path-or-module> [--scope <scope>]
91
96
  `;
92
97
  printResult(ctx, { prompt: text }, () => text);
93
98
  }
@@ -11,6 +11,7 @@ import { CliError } from '../../shared/errors.mjs';
11
11
  import { tx } from '../../db/schema.mjs';
12
12
  import { parseOpts, validateOpts } from '../../cli-args.mjs';
13
13
  import { runtimeProcessIdentity } from '../../runtime/state.mjs';
14
+ import { ensurePrivateProjectBufferDirectory } from '../../runtime/project-path.mjs';
14
15
  import { withBufferDirectoryLease } from '../../runtime/buffer-directory-lease.mjs';
15
16
  import {
16
17
  externalBufferEvidence,
@@ -178,8 +179,7 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
178
179
  const ptyModule = await import('node-pty');
179
180
  const pty = ptyModule.default || ptyModule;
180
181
 
181
- const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
182
- fs.mkdirSync(bufsDir, { recursive: true });
182
+ const bufsDir = ensurePrivateProjectBufferDirectory(ctx.root, BUFS_DIR_NAME);
183
183
  const outFile = path.join(bufsDir, `${id}.out`);
184
184
  const inFile = path.join(bufsDir, `${id}.in`);
185
185
  const resizeFile = path.join(bufsDir, `${id}.resize`);
@@ -219,8 +219,8 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
219
219
  }
220
220
  for (const file of bufferFiles) fs.rmSync(file, { force: true });
221
221
  }
222
- fs.writeFileSync(inFile, '');
223
- fs.writeFileSync(resizeFile, '');
222
+ fs.writeFileSync(inFile, '', { flag: 'wx', mode: 0o600 });
223
+ fs.writeFileSync(resizeFile, '', { flag: 'wx', mode: 0o600 });
224
224
  fs.writeFileSync(metaFile, JSON.stringify({
225
225
  id,
226
226
  generation: bufferGeneration,
@@ -231,7 +231,7 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
231
231
  wrapper_pid: process.pid,
232
232
  ...(publishingWrapperIdentity ? { wrapper_identity: publishingWrapperIdentity } : {}),
233
233
  publishing: true
234
- }));
234
+ }), { flag: 'wx', mode: 0o600 });
235
235
 
236
236
  const db = connect(ctx);
237
237
  try {
@@ -359,7 +359,8 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
359
359
  if (!ownsExternalBufferGroup()) {
360
360
  throw new CliError('EXTERNAL_SESSION_SUPERSEDED', `External session ${id} was replaced during startup`);
361
361
  }
362
- outFd = fs.openSync(outFile, 'w');
362
+ outFd = fs.openSync(outFile, fs.constants.O_WRONLY | fs.constants.O_CREAT |
363
+ fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
363
364
  });
364
365
  } catch (error) {
365
366
  await failStartup(error?.code || 'external_buffer_open_failed');
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { tx } from '../../db/schema.mjs';
3
+ import { automaticResultKey } from '../../shared/automatic-result-key.mjs';
3
4
  import { CliError } from '../../shared/errors.mjs';
4
5
 
5
6
  export const RESULT_EVENT = 'task.result.recorded';
@@ -18,7 +19,7 @@ function text(value, field, max, required = false) {
18
19
 
19
20
  // Evidence records do not mutate task status. The owner is checked in the same
20
21
  // transaction as the insert, including after a concurrent task takeover.
21
- export function recordTaskResult(db, input, { now = () => Math.floor(Date.now() / 1000), addEvent } = {}) {
22
+ export function recordTaskResult(db, input, { now = () => Math.floor(Date.now() / 1000), addEvent, automatic = false } = {}) {
22
23
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('BAD_REQUEST', 'A result object is required');
23
24
  const peer = text(input.peer, 'peer', 200, true);
24
25
  const taskId = input.taskId;
@@ -29,21 +30,35 @@ export function recordTaskResult(db, input, { now = () => Math.floor(Date.now()
29
30
  throw new CliError('BAD_REQUEST', 'Unsupported result stage, status, kind or source');
30
31
  }
31
32
  if (source === 'executor' && stage !== 'local') throw new CliError('BAD_REQUEST', 'Executor records can only report local evidence');
33
+ if (automatic && (source !== 'executor' || !['command', 'diff'].includes(kind))) {
34
+ throw new CliError('BAD_REQUEST', 'Automatic results must be local executor commands or diffs');
35
+ }
32
36
  const evidence = input.evidence || [];
33
37
  if (!Array.isArray(evidence) || evidence.length > 20) throw new CliError('BAD_REQUEST', 'evidence must contain up to 20 text references');
34
38
  const references = evidence.map((entry) => text(entry, 'evidence', 2000, true));
35
39
  if (source === 'user' && status === 'passed' && !references.length) {
36
40
  throw new CliError('BAD_REQUEST', 'A passed verification needs an evidence reference');
37
41
  }
42
+ const executorId = text(input.executorId, 'executorId', 512) || null;
43
+ const threadId = text(input.threadId, 'threadId', 512) || null;
44
+ const turnId = text(input.turnId, 'turnId', 512) || null;
45
+ const itemId = text(input.itemId, 'itemId', 512) || null;
46
+ if (automatic && (!executorId || !threadId || !turnId || (kind === 'command' && !itemId))) {
47
+ throw new CliError('BAD_REQUEST', 'Automatic results require executor, thread, turn and command item identities');
48
+ }
49
+ const resultKey = automatic
50
+ ? automaticResultKey({ executorId, threadId, turnId, kind, itemId: kind === 'command' ? itemId : null })
51
+ : input.resultKey;
38
52
  const record = {
39
- result_id: randomUUID(), result_key: text(input.resultKey, 'resultKey', 700) || randomUUID(),
53
+ result_id: randomUUID(), result_key: text(resultKey, 'resultKey', 700) || randomUUID(),
40
54
  peer, task_id: taskId, stage, status, kind, source,
41
- title: text(input.title, 'title', 300, true), details: text(input.details, 'details', 64000), evidence: references,
42
- command: text(input.command, 'command', 64000), diff: text(input.diff, 'diff', 64000),
43
- executor_id: text(input.executorId, 'executorId', 512) || null,
44
- thread_id: text(input.threadId, 'threadId', 512) || null,
45
- turn_id: text(input.turnId, 'turnId', 512) || null,
46
- item_id: text(input.itemId, 'itemId', 512) || null,
55
+ title: automatic ? (kind === 'command' ? 'Command execution' : 'Turn changes awaiting review')
56
+ : text(input.title, 'title', 300, true),
57
+ details: automatic ? '' : text(input.details, 'details', 64000),
58
+ evidence: automatic ? [] : references,
59
+ command: automatic ? '' : text(input.command, 'command', 64000),
60
+ diff: automatic ? '' : text(input.diff, 'diff', 64000),
61
+ executor_id: executorId, thread_id: threadId, turn_id: turnId, item_id: itemId,
47
62
  exit_code: Number.isSafeInteger(input.exitCode) ? input.exitCode : null,
48
63
  created_at: now()
49
64
  };
@@ -55,8 +70,10 @@ export function recordTaskResult(db, input, { now = () => Math.floor(Date.now()
55
70
  AND task_id = ? AND actor = ? AND json_valid(payload) AND json_extract(payload, '$.result_key') = ?
56
71
  ORDER BY id DESC LIMIT 1`).get(RESULT_EVENT, taskId, peer, record.result_key);
57
72
  if (previous) return { ...JSON.parse(previous.payload), id: previous.id, created_at: previous.created_at };
58
- record.task_title = task.title;
59
- record.task_status = task.status;
73
+ if (!automatic) {
74
+ record.task_title = task.title;
75
+ record.task_status = task.status;
76
+ }
60
77
  if (addEvent) addEvent(db, RESULT_EVENT, peer, taskId, record);
61
78
  else db.prepare('INSERT INTO events(type,actor,task_id,payload,created_at) VALUES(?,?,?,?,?)')
62
79
  .run(RESULT_EVENT, peer, taskId, JSON.stringify(record), record.created_at);