@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.
Files changed (141) hide show
  1. package/CHANGELOG.md +229 -108
  2. package/README.md +1 -10
  3. package/README.zh-CN.md +1 -8
  4. package/docs/codex-account.zh-CN.md +38 -0
  5. package/docs/codex-protocol.md +59 -0
  6. package/docs/commands.md +13 -28
  7. package/docs/commands.zh-CN.md +11 -20
  8. package/docs/dsh.md +5 -3
  9. package/docs/dsh.zh-CN.md +6 -4
  10. package/docs/macos-process-identity.md +15 -0
  11. package/docs/native.md +176 -6
  12. package/docs/native.zh-CN.md +138 -3
  13. package/docs/web-handoff.zh-CN.md +107 -5
  14. package/lib/cli/app.mjs +9 -6
  15. package/lib/cli/commands/coordination.mjs +7 -2
  16. package/lib/cli/commands/gc.mjs +14 -12
  17. package/lib/cli/commands/hook.mjs +16 -3
  18. package/lib/cli/commands/install.mjs +37 -9
  19. package/lib/cli/commands/lock.mjs +3 -1
  20. package/lib/cli/commands/misc.mjs +105 -13
  21. package/lib/cli/commands/native.mjs +41 -48
  22. package/lib/cli/commands/peer.mjs +3 -0
  23. package/lib/cli/commands/run.mjs +13 -6
  24. package/lib/cli/commands/tmux.mjs +1 -2
  25. package/lib/cli-runtime.mjs +19 -2
  26. package/lib/core/coordination/gc-plan.mjs +5 -0
  27. package/lib/core/coordination/locks.mjs +3 -1
  28. package/lib/core/peers/evidence.mjs +21 -1
  29. package/lib/core/peers/tmux-safety.mjs +4 -0
  30. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  31. package/lib/core/sessions/launch.mjs +7 -1
  32. package/lib/db/connection.mjs +67 -21
  33. package/lib/diagnostics/codex-protocol.mjs +105 -0
  34. package/lib/guidance.mjs +20 -36
  35. package/lib/integrations/codex-account.mjs +143 -0
  36. package/lib/integrations/dsh-collaboration.mjs +3 -4
  37. package/lib/integrations/dsh.mjs +4 -2
  38. package/lib/integrations/native/claude.mjs +53 -9
  39. package/lib/integrations/native/codex.mjs +75 -15
  40. package/lib/integrations/native/dsh-acp.mjs +77 -24
  41. package/lib/integrations/native/index.mjs +4 -1
  42. package/lib/integrations/native/interactions.mjs +16 -3
  43. package/lib/integrations/native/jsonrpc.mjs +37 -5
  44. package/lib/integrations/native/telemetry.mjs +30 -0
  45. package/lib/process/identity.mjs +67 -28
  46. package/lib/process/pinned-cwd.mjs +302 -0
  47. package/lib/process/pinned-guidance.mjs +138 -0
  48. package/lib/process/pinned-project-state.mjs +105 -0
  49. package/lib/process/selected-cwd-identity.mjs +95 -0
  50. package/lib/runtime/buffer-evidence.mjs +7 -1
  51. package/lib/runtime/client.mjs +18 -1
  52. package/lib/runtime/native/client.mjs +12 -2
  53. package/lib/runtime/native/history.mjs +140 -0
  54. package/lib/runtime/native/launcher.mjs +106 -0
  55. package/lib/runtime/native/service.mjs +346 -52
  56. package/lib/runtime/native/store.mjs +75 -21
  57. package/lib/runtime/project-path.mjs +24 -5
  58. package/lib/runtime/projects.mjs +3 -0
  59. package/lib/runtime/state.mjs +61 -26
  60. package/lib/shared/file-lock.mjs +62 -17
  61. package/lib/shared/redact.mjs +2 -1
  62. package/lib/shared/socket-lock-worker.mjs +15 -6
  63. package/lib/web/agent-defaults.mjs +110 -0
  64. package/lib/web/auto-attach.mjs +3 -2
  65. package/lib/web/browser/contracts.d.ts +10 -0
  66. package/lib/web/browser/core.mjs +2005 -0
  67. package/lib/web/browser/project-data.mjs +158 -0
  68. package/lib/web/browser/session-sync.mjs +82 -0
  69. package/lib/web/browser-assets.mjs +19 -0
  70. package/lib/web/buffer-gc-runtime.mjs +2 -2
  71. package/lib/web/codex-app-server.mjs +104 -33
  72. package/lib/web/codex-sessions.mjs +60 -11
  73. package/lib/web/context-files.mjs +82 -0
  74. package/lib/web/cookie-auth.mjs +51 -3
  75. package/lib/web/external-sessions.mjs +22 -4
  76. package/lib/web/http-routes.mjs +237 -24
  77. package/lib/web/http.mjs +50 -4
  78. package/lib/web/liveness-reaper.mjs +2 -2
  79. package/lib/web/native-sessions.mjs +370 -21
  80. package/lib/web/project-contexts.mjs +235 -42
  81. package/lib/web/project-file-write.mjs +249 -0
  82. package/lib/web/project-files.mjs +268 -0
  83. package/lib/web/pty-sessions.mjs +11 -3
  84. package/lib/web/runtime-main.mjs +134 -82
  85. package/lib/web/session-serialize.mjs +87 -8
  86. package/lib/web/session-state-sync.mjs +98 -0
  87. package/lib/web/shutdown-diagnostics.mjs +38 -0
  88. package/lib/web/shutdown-response.mjs +21 -0
  89. package/lib/web/shutdown-state.mjs +91 -0
  90. package/lib/web/startup.mjs +76 -27
  91. package/lib/web/tmux-client-snapshot.mjs +85 -0
  92. package/lib/web/tmux-sessions.mjs +71 -7
  93. package/lib/web/tmux-stream.mjs +36 -8
  94. package/lib/web/ui-agent-defaults.mjs +149 -0
  95. package/lib/web/ui-agent-start.mjs +330 -0
  96. package/lib/web/ui-codex-account.mjs +81 -0
  97. package/lib/web/ui-codex.mjs +81 -73
  98. package/lib/web/ui-command-palette.mjs +21 -5
  99. package/lib/web/ui-file-writes.mjs +220 -0
  100. package/lib/web/ui-files.mjs +231 -0
  101. package/lib/web/ui-handoff.mjs +10 -2
  102. package/lib/web/ui-history.mjs +175 -105
  103. package/lib/web/ui-i18n.mjs +286 -8
  104. package/lib/web/ui-interactions.mjs +6 -4
  105. package/lib/web/ui-native-timeline.mjs +326 -0
  106. package/lib/web/ui-native.mjs +115 -51
  107. package/lib/web/ui-review.mjs +5 -3
  108. package/lib/web/ui-safe-markdown.mjs +79 -0
  109. package/lib/web/ui-session-tools.mjs +224 -0
  110. package/lib/web/ui-template.mjs +29 -1969
  111. package/lib/web/ui-terminal-find.mjs +5 -3
  112. package/lib/web/ui-workbench.mjs +5 -3
  113. package/lib/web/ui-workspace.mjs +9 -2
  114. package/package.json +8 -2
  115. package/scripts/benchmark-session-sync.mjs +53 -0
  116. package/scripts/codex-protocol.mjs +88 -0
  117. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  118. package/scripts/dsh-package-provenance.mjs +118 -0
  119. package/scripts/helpers/gc-clock-observation.mjs +33 -0
  120. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  121. package/scripts/native-fixture-root.mjs +49 -0
  122. package/scripts/native-installed-acceptance.mjs +284 -25
  123. package/scripts/native-stability-checks.mjs +40 -0
  124. package/scripts/regression-cookie-expiry.mjs +70 -0
  125. package/scripts/regression-http-transport.mjs +9 -0
  126. package/scripts/regression-terminal-marker.mjs +95 -0
  127. package/scripts/regression.mjs +655 -188
  128. package/scripts/shim-reentry-probe.mjs +105 -0
  129. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  130. package/scripts/web-codex-account-acceptance.mjs +178 -0
  131. package/scripts/web-file-preview-checks.mjs +302 -0
  132. package/scripts/web-mcp-form-acceptance.mjs +22 -7
  133. package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
  134. package/scripts/web-native-handoff-checks.mjs +128 -0
  135. package/scripts/web-native-interaction-acceptance.mjs +152 -18
  136. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  137. package/scripts/web-native-model-acceptance.mjs +22 -7
  138. package/scripts/web-native-retry-checks.mjs +177 -0
  139. package/scripts/web-session-tools-checks.mjs +112 -0
  140. package/scripts/web-workbench-acceptance.mjs +438 -0
  141. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -1,20 +1,17 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { spawn } from 'node:child_process';
4
- import { setTimeout as delay } from 'node:timers/promises';
5
3
  import { CliError } from '../../shared/errors.mjs';
6
4
  import { parseOpts, validateOpts, wantsHelp, intOpt } from '../../cli-args.mjs';
7
5
  import { printResult } from '../../format.mjs';
8
- import { commandPath } from '../../cli-runtime.mjs';
9
- import { nativePaths, readNativePointer } from '../../runtime/native/store.mjs';
10
- import { openPrivateAppendFile } from '../../runtime/private-file.mjs';
6
+ import { ensureNativeRuntime } from '../../runtime/native/launcher.mjs';
11
7
  import { nativeRequest } from '../../runtime/native/client.mjs';
12
8
  import { startNativeService } from '../../runtime/native/service.mjs';
13
9
 
14
10
  export function nativeHelp() {
15
11
  console.log(`Usage: hcc native up|status|down
16
- hcc native start --peer NAME --provider codex|claude|dsh [--cwd DIR] [--model MODEL] [--binary PATH] [--resume last]
17
- hcc native send --peer NAME --body TEXT [--from NAME] [--task ID]
12
+ hcc native start --peer NAME --provider codex|claude|dsh [--cwd DIR] [--model MODEL] [--binary PATH] [--resume last] [--sandbox read-only|workspace-write]
13
+ hcc native fork --parent NAME --peer NEW_NAME [--model MODEL] [--binary PATH]
14
+ hcc native send --peer NAME --body TEXT [--from NAME] [--task ID] [--submission-id ID]
18
15
  hcc native events --peer NAME [--after ID]
19
16
  hcc native deliveries [--peer NAME]
20
17
  hcc native requests --peer NAME
@@ -24,69 +21,51 @@ export function nativeHelp() {
24
21
 
25
22
  Native workers share the project task/message/lock bus. Only HCC-owned saved
26
23
  sessions can resume. Existing TUI/Desktop sessions keep their own transport.
24
+ Use a unique --submission-id for each new message and reuse it only for an
25
+ exact retry after an uncertain receipt.
27
26
  Providers retain their permission checks; interactive requests await an explicit Web or local response.
27
+ Codex alone accepts --sandbox. New sessions default to workspace-write; resumed and forked sessions inherit their saved policy.
28
+ read-only disables permission escalation and HCC scoped MCP tools. It is a provider file sandbox, not an all-tools/network guarantee.
28
29
  Claude workers require the optional @anthropic-ai/claude-agent-sdk package.
29
30
  dsh workers use the ACP profile, not its Web-service terminal.`);
30
31
  }
31
32
 
32
33
  export function createNativeCommands(deps) {
33
- async function up(ctx) {
34
- if (readNativePointer(ctx)) {
35
- try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 2000 }); }
36
- catch (error) {
37
- if (error.code !== 'NATIVE_RUNTIME_OFFLINE') throw error;
38
- }
39
- }
40
- const paths = nativePaths(ctx, { create: true });
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);
34
+ function requireSandboxRuntime(runtime) {
35
+ if (runtime?.sandboxPolicyVersion !== 1) {
36
+ throw new CliError('NATIVE_SANDBOX_RUNTIME_UNSUPPORTED',
37
+ 'This native runtime cannot preserve Codex sandbox policies. Stop the project native runtime after its work has finished, then start it with this HCC version. No worker was created.');
55
38
  }
56
- let failed;
57
- child.on('error', (error) => { failed = error; });
58
- child.unref();
59
- const deadline = Date.now() + 10000;
60
- while (Date.now() < deadline) {
61
- if (failed) throw new CliError('NATIVE_RUNTIME_START_FAILED', failed.message);
62
- if (readNativePointer(ctx)) {
63
- try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 1000 }); } catch {}
64
- }
65
- await delay(100);
39
+ if (typeof runtime?.generation !== 'string' ||
40
+ !runtime.generation.trim() || runtime.generation.length > 128) {
41
+ throw new CliError('NATIVE_OWNER_CHANGED', 'Native runtime did not report a verifiable generation. No worker was created.');
66
42
  }
67
- throw new CliError('NATIVE_RUNTIME_START_FAILED', `Native runtime did not become ready. Inspect ${paths.log}`);
68
43
  }
69
-
70
44
  async function cmdNative(ctx, args) {
71
45
  const command = args[0];
72
46
  if (!command || wantsHelp(args)) return nativeHelp();
73
47
  const opts = parseOpts(args.slice(1));
74
48
  const allowed = {
75
49
  up: [], serve: [], down: [], status: [],
76
- start: ['peer', 'provider', 'cwd', 'model', 'binary', 'resume'],
77
- send: ['peer', 'body', 'from', 'task'],
50
+ start: ['peer', 'provider', 'cwd', 'model', 'binary', 'resume', 'sandbox'],
51
+ send: ['peer', 'body', 'from', 'task', 'submission-id'],
78
52
  events: ['peer', 'after'], deliveries: ['peer'], requests: ['peer'],
79
53
  respond: ['peer', 'request', 'decision', 'response-file'],
54
+ fork: ['peer', 'parent', 'model', 'binary'],
80
55
  interrupt: ['peer', 'turn'], close: ['peer']
81
56
  };
82
57
  if (!Object.hasOwn(allowed, command)) throw new CliError('BAD_ARGS', `Unknown native command: ${command}`);
83
58
  validateOpts(`native ${command}`, opts, allowed[command]);
84
- if (['start', 'send', 'events', 'interrupt', 'close', 'requests', 'respond'].includes(command) && !opts.peer) throw new CliError('BAD_ARGS', 'Missing --peer');
59
+ if (['start', 'fork', 'send', 'events', 'interrupt', 'close', 'requests', 'respond'].includes(command) && !opts.peer) throw new CliError('BAD_ARGS', 'Missing --peer');
85
60
  if (command === 'start' && !opts.provider) throw new CliError('BAD_ARGS', 'Missing --provider');
86
61
  if (command === 'start' && opts.provider === 'claude' && opts.binary !== undefined) throw new CliError('BAD_ARGS', 'Claude workers use the Agent SDK; --binary is available for Codex and dsh');
62
+ if (command === 'start' && opts.sandbox !== undefined &&
63
+ (opts.provider !== 'codex' || !['read-only', 'workspace-write'].includes(opts.sandbox))) {
64
+ throw new CliError('BAD_ARGS', '--sandbox accepts read-only or workspace-write for Codex only');
65
+ }
87
66
  if (command === 'send' && !opts.body?.trim()) throw new CliError('BAD_ARGS', 'Missing --body');
88
67
  let result;
89
- if (command === 'up') result = await up(ctx);
68
+ if (command === 'up') result = await ensureNativeRuntime(ctx);
90
69
  else if (command === 'serve') {
91
70
  const service = await startNativeService(ctx, deps);
92
71
  const shutdown = () => { void service.shutdown().catch((error) => console.error(JSON.stringify({ code: error.code, message: error.message }))); };
@@ -94,14 +73,28 @@ export function createNativeCommands(deps) {
94
73
  process.once('SIGINT', shutdown);
95
74
  return;
96
75
  } else if (command === 'start') {
97
- await up(ctx);
76
+ const runtime = await ensureNativeRuntime(ctx);
77
+ if (opts.provider === 'codex') requireSandboxRuntime(runtime);
98
78
  result = await nativeRequest(ctx, 'POST', '/workers', { peer: opts.peer, provider: opts.provider,
99
- cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume });
79
+ cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume,
80
+ ...(opts.sandbox !== undefined ? { sandbox: opts.sandbox } : {}) },
81
+ opts.provider === 'codex' ? { expectedGeneration: runtime.generation } : undefined);
82
+ } else if (command === 'fork') {
83
+ if (!opts.parent) throw new CliError('BAD_ARGS', 'Missing --parent');
84
+ const runtime = await nativeRequest(ctx, 'GET', '/status');
85
+ requireSandboxRuntime(runtime);
86
+ result = await nativeRequest(ctx, 'POST', '/fork', { parent: opts.parent, peer: opts.peer, model: opts.model, binary: opts.binary },
87
+ { expectedGeneration: runtime.generation });
100
88
  } else if (command === 'send') {
101
89
  const taskId = intOpt(opts, 'task', null);
102
90
  if (taskId !== null && taskId < 1) throw new CliError('BAD_ARGS', 'Task ID must be positive');
91
+ const submissionId = opts['submission-id'];
92
+ if (submissionId !== undefined && !/^[A-Za-z0-9_-]{8,100}$/.test(submissionId)) {
93
+ throw new CliError('BAD_ARGS', 'Submission ID must contain 8 to 100 letters, digits, underscores, or hyphens');
94
+ }
103
95
  const sender = deps.resolveCurrentPeer(ctx, opts, 'from', 'shell').id;
104
- result = await nativeRequest(ctx, 'POST', '/send', { peer: opts.peer, from: sender, body: opts.body, taskId });
96
+ result = await nativeRequest(ctx, 'POST', '/send', { peer: opts.peer, from: sender, body: opts.body, taskId,
97
+ ...(submissionId ? { submissionId } : {}) });
105
98
  } else if (command === 'events') {
106
99
  const after = intOpt(opts, 'after', 0);
107
100
  if (after < 0) throw new CliError('BAD_ARGS', 'Event cursor must be nonnegative');
@@ -4,6 +4,7 @@ import {
4
4
  RUNTIME_SESSION_MUTATION_TIMEOUT_MS,
5
5
  RUNTIME_SESSION_START_TIMEOUT_MS
6
6
  } from '../../runtime/client.mjs';
7
+ import { captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
7
8
 
8
9
  export function createPeerCommands(deps) {
9
10
  const {
@@ -75,6 +76,7 @@ async function peerStart(ctx, args) {
75
76
  const kind = inferPeerKind(id, opts.kind, firstCommand);
76
77
  const role = opts.role || 'peer';
77
78
  const cwd = path.resolve(opts.cwd || ctx.root);
79
+ const cwdIdentity = captureSelectedCwdSnapshot(cwd);
78
80
  const built = buildPeerCommand(id, kind, opts, cmdArgs);
79
81
  const command = built.command;
80
82
  const binding = { ...built.binding, transport: 'tmux', runtime_session_id: id };
@@ -139,6 +141,7 @@ async function peerStart(ctx, args) {
139
141
  role,
140
142
  command,
141
143
  cwd,
144
+ cwdIdentity,
142
145
  binding,
143
146
  env: childSessionEnv(),
144
147
  restartOnEnvChange: Boolean(opts['restart-env']),
@@ -6,8 +6,9 @@ import path from 'node:path';
6
6
  import fs from 'node:fs';
7
7
  import process from 'node:process';
8
8
  import { randomBytes } from 'node:crypto';
9
- import { spawn } from 'node:child_process';
10
9
  import { CliError } from '../../shared/errors.mjs';
10
+ import { spawnPinnedCwdProcess, spawnPinnedCwdPty } from '../../process/pinned-cwd.mjs';
11
+ import { captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
11
12
  import { tx } from '../../db/schema.mjs';
12
13
  import { parseOpts, validateOpts } from '../../cli-args.mjs';
13
14
  import { runtimeProcessIdentity } from '../../runtime/state.mjs';
@@ -57,6 +58,7 @@ async function cmdRun(ctx, args) {
57
58
  const id = identity.id;
58
59
  const role = opts.role || 'peer';
59
60
  const cwd = path.resolve(opts.cwd || ctx.cwd);
61
+ const expectedCwdIdentity = captureSelectedCwdSnapshot(cwd);
60
62
  const command = cmdArgs.length ? cmdArgs[0] : defaultSessionCommand(kind);
61
63
  const commandArgs = cmdArgs.length ? cmdArgs.slice(1) : [];
62
64
  const binding = bindingFromRun(id, kind, command, commandArgs, 'hcc-run');
@@ -67,6 +69,7 @@ async function cmdRun(ctx, args) {
67
69
  kind,
68
70
  role,
69
71
  cwd,
72
+ expectedCwdIdentity,
70
73
  command,
71
74
  commandArgs,
72
75
  binding,
@@ -131,10 +134,12 @@ async function cmdRun(ctx, args) {
131
134
  db.close();
132
135
  }
133
136
  console.error(redactedLogText(`${CLI_NAME}: running ${id} (${kind}, ${role}) -> ${command} ${commandArgs.join(' ')}`.trim()));
134
- const child = spawn(command, commandArgs, {
137
+ const child = spawnPinnedCwdProcess(command, commandArgs, {
135
138
  cwd,
139
+ expectedIdentity: expectedCwdIdentity,
136
140
  stdio: 'inherit',
137
- env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath })
141
+ env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath },
142
+ process.env, { rootIdentity: ctx.initialRootIdentity })
138
143
  });
139
144
  const exitCode = await new Promise((resolve) => {
140
145
  child.on('exit', (code, signal) => resolve({ code, signal }));
@@ -174,7 +179,7 @@ async function cmdRun(ctx, args) {
174
179
  * the local terminal and a shared buffer file so hcc web can stream it to
175
180
  * browsers. Input from the browser is written to a .in file that we relay.
176
181
  */
177
- async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs, binding, force = false }) {
182
+ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity, command, commandArgs, binding, force = false }) {
178
183
  registerProjectActivity(ctx);
179
184
  const ptyModule = await import('node-pty');
180
185
  const pty = ptyModule.default || ptyModule;
@@ -265,9 +270,11 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
265
270
  const cols = process.stdout.columns || 120;
266
271
  const rows = process.stdout.rows || 40;
267
272
 
268
- const child = pty.spawn(command, commandArgs, {
273
+ const child = spawnPinnedCwdPty(pty, command, commandArgs, {
269
274
  name: 'xterm-256color', cols, rows, cwd,
270
- env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, TERM: 'xterm-256color' })
275
+ expectedIdentity: expectedCwdIdentity,
276
+ env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, TERM: 'xterm-256color' },
277
+ process.env, { rootIdentity: ctx.initialRootIdentity })
271
278
  });
272
279
  const childExit = trackPtyExit(child);
273
280
  const wrapperTermination = installPtyTerminationHandlers(child);
@@ -2,7 +2,6 @@
2
2
  // hcc scan detects provider sessions; the tmux helpers implement DB-proven,
3
3
  // evidence-checked tmux GC (plan/validate/execute) for hcc-managed sessions.
4
4
 
5
- import path from 'node:path';
6
5
  import process from 'node:process';
7
6
  import { spawnSync } from 'node:child_process';
8
7
  import { CliError } from '../../shared/errors.mjs';
@@ -527,7 +526,7 @@ async function planTmuxGc(ctx, opts) {
527
526
  continue;
528
527
  }
529
528
  const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');
530
- if (hccRoot && path.resolve(hccRoot) !== path.resolve(ctx.root)) {
529
+ if (hccRoot && rootEvidence(ctx.root, hccRoot).state !== 'match') {
531
530
  skip('hcc_root_mismatch', { hcc_root: hccRoot });
532
531
  continue;
533
532
  }
@@ -2,15 +2,32 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { projectDbPath } from './runtime/paths.mjs';
5
+ import { captureSelectedCwdSnapshot } from './process/selected-cwd-identity.mjs';
5
6
 
6
7
  export function createContext(global = {}, deps = {}) {
7
8
  const cwd = deps.cwd || process.cwd();
8
9
  const detectRoot = deps.detectRoot;
9
10
  if (typeof detectRoot !== 'function') throw new TypeError('createContext requires deps.detectRoot');
10
- const root = detectRoot(cwd, global.root);
11
+ const requestedRoot = detectRoot(cwd, global.root);
12
+ // Freeze an existing root's canonical pathname for this command. A symlink
13
+ // supplied through --root (or HCC_ROOT) may be retargeted after context
14
+ // construction; later state and runtime path derivations must not silently
15
+ // switch projects. Missing roots retain their path for commands that create
16
+ // a project directory.
17
+ let root;
18
+ let initialRootIdentity = null;
19
+ try {
20
+ root = fs.realpathSync.native(requestedRoot);
21
+ initialRootIdentity = captureSelectedCwdSnapshot(root);
22
+ }
23
+ catch (error) {
24
+ if (error?.code !== 'ENOENT') throw error;
25
+ root = requestedRoot;
26
+ }
11
27
  const envDb = global.root ? null : process.env.HCC_DB;
12
28
  const dbPath = path.resolve(global.db || envDb || projectDbPath(root));
13
- return { cwd, root, dbPath, json: global.json, explicitRoot: Boolean(global.root || process.env.HCC_ROOT) };
29
+ return { cwd, root, dbPath, json: global.json,
30
+ explicitRoot: Boolean(global.root || process.env.HCC_ROOT), initialRootIdentity };
14
31
  }
15
32
 
16
33
  export function shellCommand(args, quoteArg) {
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
 
3
3
  import { readTx, tx } from '../../db/schema.mjs';
4
4
  import { aggregateCleanupFailure } from '../../shared/cleanup-error.mjs';
5
+ import { isPeerEvidenceCompatibilityProtected } from '../peers/evidence.mjs';
5
6
  import { DURABLE_RUNTIME_EVENTS } from './event-retention.mjs';
6
7
 
7
8
  export const HISTORY_GC_BATCH_SIZE = 256;
@@ -59,6 +60,10 @@ export function finalizeGcLockSubjects(db, subjects, evidenceByOwner, options =
59
60
  result.live += 1;
60
61
  continue;
61
62
  }
63
+ if (isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(owner))) {
64
+ result.deferred += 1;
65
+ continue;
66
+ }
62
67
  if (dryRun || deleteExactRow(db, 'locks', planned.lock)) result.deleted += 1;
63
68
  else result.deferred += 1;
64
69
  }
@@ -1,4 +1,5 @@
1
1
  import { CliError } from '../../shared/errors.mjs';
2
+ import { isPeerEvidenceCompatibilityProtected } from '../peers/evidence.mjs';
2
3
 
3
4
  export const WHOLE_LOCK_SCOPE = '*';
4
5
 
@@ -87,5 +88,6 @@ export function visibleLocksWithEvidence(locks, nowSec, {
87
88
  evidenceByOwner = new Map()
88
89
  } = {}) {
89
90
  return (locks || []).filter((lock) => graceActive || Number(lock.expires_at) > nowSec ||
90
- evidenceByOwner.get(lock.owner)?.state === 'live');
91
+ evidenceByOwner.get(lock.owner)?.state === 'live' ||
92
+ isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(lock.owner)));
91
93
  }
@@ -1,18 +1,26 @@
1
- import { compareProcessIdentity } from '../../process/identity.mjs';
1
+ import { compareProcessIdentity, isProcessIdentityIncompatible } from '../../process/identity.mjs';
2
2
 
3
3
  const DEFAULT_ACTIVE_PEER_TTL = 600;
4
+ const INCOMPATIBLE_PROCESS_REASON = 'process_identity_incompatible';
5
+
6
+ export function isPeerEvidenceCompatibilityProtected(evidence = {}) {
7
+ return evidence.state === 'unknown' && evidence.reason === INCOMPATIBLE_PROCESS_REASON;
8
+ }
4
9
 
5
10
  export function classifyPeerActivity(peer = {}, options = {}) {
6
11
  const activePeerTtl = Number(options.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
7
12
  const graceActive = Boolean(options.graceActive);
8
13
  const evidenceState = peer.evidence_state || peer.evidence?.state || 'unknown';
14
+ const evidenceReason = peer.evidence_reason || peer.evidence?.reason;
9
15
  const age = Number(peer.age_sec);
10
16
  const active = evidenceState === 'live' ||
17
+ isPeerEvidenceCompatibilityProtected({ state: evidenceState, reason: evidenceReason }) ||
11
18
  (evidenceState !== 'dead' && (graceActive || (Number.isFinite(age) && age <= activePeerTtl)));
12
19
  return { active, stale: !active };
13
20
  }
14
21
 
15
22
  export function peerEvidenceAllowsReap(evidence = {}, options = {}) {
23
+ if (isPeerEvidenceCompatibilityProtected(evidence)) return false;
16
24
  if (evidence.state === 'dead') return true;
17
25
  if (evidence.state !== 'unknown') return false;
18
26
  const nowSec = Number(options.nowSec);
@@ -37,6 +45,9 @@ function processState(evidence) {
37
45
  if (evidence.current?.state === 'unknown') return 'unknown';
38
46
  if (evidence.current?.state === 'dead') return 'dead';
39
47
  if (evidence.current?.state !== 'live') return 'unknown';
48
+ if (isProcessIdentityIncompatible(evidence.storedIdentity, evidence.current.identity)) {
49
+ return 'incompatible';
50
+ }
40
51
  return compareProcessIdentity(evidence.storedIdentity, evidence.current.identity);
41
52
  }
42
53
 
@@ -48,6 +59,9 @@ function aggregateProcesses(processes) {
48
59
  if (states.includes('live')) {
49
60
  return { state: 'live', reason: 'process_identity_match' };
50
61
  }
62
+ if (states.includes('incompatible')) {
63
+ return { state: 'unknown', reason: INCOMPATIBLE_PROCESS_REASON };
64
+ }
51
65
  if (states.every((state) => state === 'dead')) {
52
66
  const missing = processes.every((item) => item?.current?.state === 'dead');
53
67
  return {
@@ -81,8 +95,14 @@ function tmuxVerifiedLive(tmux) {
81
95
  export function resolvePeerEvidence(input = {}) {
82
96
  const ordinaryProcess = aggregateProcesses(input.processes);
83
97
  if (ordinaryProcess.state === 'live') return ordinaryProcess;
98
+ // A live PID with incomparable Darwin generations proves neither ownership
99
+ // nor exit. Retain that distinction through persisted status and TTL gates.
100
+ if (isPeerEvidenceCompatibilityProtected(ordinaryProcess)) return ordinaryProcess;
84
101
 
85
102
  const tmux = input.tmux?.managed ? input.tmux : null;
103
+ if (ordinaryProcess.state === 'unknown' && tmux && tmuxProcessState(tmux) === 'incompatible') {
104
+ return { state: 'unknown', reason: INCOMPATIBLE_PROCESS_REASON };
105
+ }
86
106
  if (ordinaryProcess.state === 'unknown' && tmux && tmuxVerifiedLive(tmux)) {
87
107
  return { state: 'live', reason: 'tmux_identity_match' };
88
108
  }
@@ -1,6 +1,7 @@
1
1
  import { compareProcessIdentity } from '../../process/identity.mjs';
2
2
  import { tx } from '../../db/schema.mjs';
3
3
  import { CliError } from '../../shared/errors.mjs';
4
+ import { isPeerEvidenceCompatibilityProtected } from './evidence.mjs';
4
5
 
5
6
  function completeIdentity(identity) {
6
7
  return Boolean(
@@ -84,6 +85,9 @@ export function validateTmuxGcBindingEvidence(subject = {}, observed = {}, optio
84
85
  if (subject.owner_evidence?.state === 'live') {
85
86
  return { ok: false, reason: 'tmux_owner_process_live' };
86
87
  }
88
+ if (isPeerEvidenceCompatibilityProtected(subject.owner_evidence)) {
89
+ return { ok: false, reason: 'tmux_owner_process_incompatible' };
90
+ }
87
91
 
88
92
  const dead = validateTmuxGcDeadProcessEvidence(authority, observed, options);
89
93
  if (dead.ok) return { ok: true, mode: 'dead_process' };
@@ -0,0 +1,47 @@
1
+ import path from 'node:path';
2
+ import {
3
+ assertSelectedCwdSnapshot,
4
+ captureSelectedCwdSnapshot,
5
+ sameSelectedCwdIdentity
6
+ } from '../../process/selected-cwd-identity.mjs';
7
+ import { CliError } from '../../shared/errors.mjs';
8
+
9
+ export const HOOK_ROOT_IDENTITY_ENV = 'HCC_HOOK_ROOT_IDENTITY';
10
+
11
+ function mismatch() {
12
+ return new CliError('HOOK_ROOT_IDENTITY_MISMATCH',
13
+ 'Hook project root no longer matches the provider launch identity');
14
+ }
15
+
16
+ // Carry the launch-time directory identity into short-lived hook children.
17
+ // A pathname alone cannot distinguish a moved project from another directory
18
+ // subsequently installed at the same pathname.
19
+ export function hookRootIdentityValue(root, original = null) {
20
+ const selected = captureSelectedCwdSnapshot(root);
21
+ if (original) {
22
+ if (typeof original.assertUnchanged === 'function') original.assertUnchanged();
23
+ else assertSelectedCwdSnapshot(original);
24
+ if (!sameSelectedCwdIdentity(selected, original)) throw mismatch();
25
+ }
26
+ return JSON.stringify({ version: 1, ...selected });
27
+ }
28
+
29
+ export function assertHookRootIdentity(root, value) {
30
+ if (value === undefined) return false; // Unmanaged and pre-upgrade hooks have no launch marker.
31
+ try {
32
+ if (typeof value !== 'string' || value.length > 4096) throw mismatch();
33
+ const snapshot = JSON.parse(value);
34
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) ||
35
+ snapshot.version !== 1 || snapshot.requested !== path.resolve(root) ||
36
+ typeof snapshot.canonical !== 'string' || !path.isAbsolute(snapshot.canonical) ||
37
+ !snapshot.identity || typeof snapshot.identity !== 'object' ||
38
+ !/^\d+$/.test(snapshot.identity.dev) || !/^\d+$/.test(snapshot.identity.ino) ||
39
+ (snapshot.identity.birthtimeNs !== null && !/^\d+$/.test(snapshot.identity.birthtimeNs))) {
40
+ throw mismatch();
41
+ }
42
+ assertSelectedCwdSnapshot(snapshot);
43
+ return snapshot;
44
+ } catch {
45
+ throw mismatch();
46
+ }
47
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { shortHash } from '../peers/format.mjs';
5
+ import { HOOK_ROOT_IDENTITY_ENV, hookRootIdentityValue } from './hook-root-identity.mjs';
5
6
 
6
7
  export const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
7
8
  export const LAUNCH_FINGERPRINT_ENV = 'HCC_LAUNCH_FINGERPRINT';
@@ -11,6 +12,7 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
11
12
  '_',
12
13
  'COLUMNS',
13
14
  'HCC_DB',
15
+ HOOK_ROOT_IDENTITY_ENV,
14
16
  'HCC_NO_AUTO_INSTALL_TMUX',
15
17
  'HCC_PEER',
16
18
  'HCC_ROOT',
@@ -41,8 +43,12 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
41
43
  'SSH_AUTH_SOCK'
42
44
  ]);
43
45
 
44
- export function childSessionEnv(extra = {}, baseEnv = process.env) {
46
+ export function childSessionEnv(extra = {}, baseEnv = process.env, { rootIdentity = null } = {}) {
45
47
  const env = { ...(baseEnv || {}), ...extra };
48
+ if (Object.hasOwn(extra, 'HCC_ROOT')) {
49
+ if (extra.HCC_ROOT) env[HOOK_ROOT_IDENTITY_ENV] = hookRootIdentityValue(extra.HCC_ROOT, rootIdentity);
50
+ else delete env[HOOK_ROOT_IDENTITY_ENV];
51
+ }
46
52
  delete env[WEB_CHILD_ENV];
47
53
  delete env.HCC_RUNTIME_LOCAL_ONLY;
48
54
  return env;
@@ -10,7 +10,8 @@ import { DatabaseSync } from 'node:sqlite';
10
10
  import { CliError } from '../shared/errors.mjs';
11
11
  import {
12
12
  DB_SCHEMA_VERSION,
13
- execWithBusyRetry
13
+ execWithBusyRetry,
14
+ readSchemaVersion
14
15
  } from './schema.mjs';
15
16
  import { initSchemaWithBackup } from './migration-backup.mjs';
16
17
  import { readProjectRegistry } from '../runtime/projects.mjs';
@@ -18,6 +19,7 @@ import { resolveProjectDatabase, secureExistingProjectStateDirectory } from '../
18
19
  import { projectStateDir, legacyProjectStateDir } from '../runtime/paths.mjs';
19
20
  import { privateProjectStateDir, ensurePrivateProjectStateDir } from '../runtime/private-state.mjs';
20
21
  import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
22
+ import { assertSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
21
23
 
22
24
  const MIGRATION_FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
23
25
 
@@ -28,16 +30,40 @@ function managedDatabase(ctx) {
28
30
  try { canonicalRoot = fs.realpathSync.native(requestedRoot); } catch {}
29
31
  const requestedDb = path.resolve(ctx.dbPath);
30
32
  let canonicalCandidate = requestedDb;
31
- try {
32
- canonicalCandidate = path.join(fs.realpathSync.native(path.dirname(requestedDb)), path.basename(requestedDb));
33
- } catch {}
34
- return [requestedRoot, canonicalRoot].some(root =>
33
+ let parent = path.dirname(requestedDb);
34
+ const missingParents = [];
35
+ for (;;) {
36
+ try {
37
+ canonicalCandidate = path.join(fs.realpathSync.native(parent),
38
+ ...missingParents, path.basename(requestedDb));
39
+ break;
40
+ } catch (error) {
41
+ if (error?.code !== 'ENOENT' || path.dirname(parent) === parent) break;
42
+ missingParents.unshift(path.basename(parent));
43
+ parent = path.dirname(parent);
44
+ }
45
+ }
46
+ const candidates = new Set([requestedDb, canonicalCandidate]);
47
+ try { candidates.add(fs.realpathSync.native(requestedDb)); } catch {}
48
+ const within = (parent, candidate) => {
49
+ const relative = path.relative(parent, candidate);
50
+ return relative !== '' && relative !== '..' &&
51
+ !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
52
+ };
53
+ const managed = [requestedRoot, canonicalRoot].some(root =>
35
54
  [legacyProjectStateDir(root), privateProjectStateDir(root)].some(stateDir =>
36
- [requestedDb, canonicalCandidate].some(db => {
37
- const relative = path.relative(stateDir, db);
38
- return relative !== '' && relative !== '..' &&
39
- !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
40
- })));
55
+ [...candidates].some(db => within(stateDir, db))));
56
+ if (managed) return true;
57
+ // A context may have captured A's local or private .hello-cc DB before its
58
+ // root alias was repointed to B. A reserved state path is never an explicit
59
+ // external DB just because it no longer belongs to the current root.
60
+ const reservedStatePath = db => path.dirname(db).split(path.sep).some(segment =>
61
+ segment.toLowerCase() === '.hello-cc');
62
+ if ([...candidates].some(reservedStatePath)) {
63
+ throw new CliError('PROJECT_PATH_FORBIDDEN',
64
+ `Private database path is not bound to the current project root: ${requestedDb}`);
65
+ }
66
+ return false;
41
67
  }
42
68
 
43
69
  function secureOwnedSqliteFile(file, { create = false, readOnly = false, optional = false } = {}) {
@@ -115,6 +141,11 @@ export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogTe
115
141
  const migratedRegisteredProjectDbs = new Set();
116
142
 
117
143
  function connect(ctx, options = {}) {
144
+ const existingSchema = options.existingSchema === true;
145
+ if (existingSchema && options.create !== false) {
146
+ throw new TypeError('existingSchema requires create: false');
147
+ }
148
+ if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
118
149
  let dbPath = ctx.dbPath;
119
150
  if (managedDatabase(ctx)) {
120
151
  const rootWasMissing = options.create !== false && !fs.existsSync(ctx.root);
@@ -144,20 +175,35 @@ export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogTe
144
175
  }
145
176
  if (options.create !== false) fs.mkdirSync(path.dirname(dbPath), { recursive: true });
146
177
  }
147
- const db = new DatabaseSync(dbPath, { timeout: 5000 });
148
- db.exec('PRAGMA busy_timeout = 5000;');
149
- execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
150
- db.exec('PRAGMA synchronous = NORMAL;');
151
- db.exec('PRAGMA wal_autocheckpoint = 1000;');
152
- db.exec('PRAGMA foreign_keys = ON;');
153
- initSchemaWithBackup(db, dbPath, {
154
- beforePostMigrationIndexes: dedupePeerBindings
155
- });
156
- if (options.migrateRegistered !== false) migrateRegisteredProjectDbs(ctx);
157
- return db;
178
+ const db = new DatabaseSync(dbPath, { timeout: existingSchema ? 0 : 5000 });
179
+ try {
180
+ if (existingSchema) {
181
+ // Shutdown may abandon advisory detachment when another writer owns
182
+ // the DB. Never migrate, change journal mode, or retry while exiting.
183
+ db.exec('PRAGMA busy_timeout = 0; PRAGMA foreign_keys = ON;');
184
+ if (readSchemaVersion(db) !== DB_SCHEMA_VERSION) {
185
+ throw new CliError('DB_SCHEMA_NOT_CURRENT', 'Shutdown requires an existing current database schema');
186
+ }
187
+ return db;
188
+ }
189
+ db.exec('PRAGMA busy_timeout = 5000;');
190
+ execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
191
+ db.exec('PRAGMA synchronous = NORMAL;');
192
+ db.exec('PRAGMA wal_autocheckpoint = 1000;');
193
+ db.exec('PRAGMA foreign_keys = ON;');
194
+ initSchemaWithBackup(db, dbPath, {
195
+ beforePostMigrationIndexes: dedupePeerBindings
196
+ });
197
+ if (options.migrateRegistered !== false) migrateRegisteredProjectDbs(ctx);
198
+ return db;
199
+ } catch (error) {
200
+ try { db.close(); } catch {}
201
+ throw error;
202
+ }
158
203
  }
159
204
 
160
205
  function connectReadOnly(ctx) {
206
+ if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
161
207
  const managed = managedDatabase(ctx);
162
208
  const dbPath = managed
163
209
  ? resolveProjectDatabase({ root: ctx.root, db: ctx.dbPath, createStateDir: false }).db