@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3

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 (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -4,6 +4,8 @@
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';
8
+ import { HOOK_ROOT_IDENTITY_ENV, assertHookRootIdentity } from '../../core/sessions/hook-root-identity.mjs';
7
9
 
8
10
  export function createHookCommand(deps) {
9
11
  const {
@@ -85,21 +87,34 @@ async function cmdHook(ctx, args) {
85
87
  const hccRoot = (nativeOwner || !isDsh) && process.env.HCC_ROOT
86
88
  ? path.resolve(process.env.HCC_ROOT)
87
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;
88
96
 
89
97
  const dshProject = isDsh && !nativeOwner ? resolveProjectDatabase({ root: hccRoot, createStateDir: false }) : null;
90
98
  const nativeProject = nativeOwner ? resolveProjectDatabase({ root: hccRoot,
91
99
  db: process.env.HCC_DB, createStateDir: false }) : null;
92
- const ordinaryDb = (ctx.root && path.resolve(ctx.root) === hccRoot ? ctx.dbPath : null) ||
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) ||
93
106
  process.env.HCC_DB;
94
107
  if (nativeProject && !fs.existsSync(nativeProject.db)) {
95
108
  throw new CliError('NATIVE_HOOK_OWNERSHIP_MISMATCH', 'Native hook database must already be owned by its runtime');
96
109
  }
110
+ const ordinaryDefaultDb = !dshProject && !nativeProject && !ordinaryDb
111
+ ? projectDbPath(hccRoot) : null;
97
112
  const hookCtx = {
98
113
  ...ctx,
99
114
  root: nativeProject?.root || dshProject?.root || hccRoot,
100
- dbPath: nativeProject?.db || dshProject?.db || (ordinaryDb ? path.resolve(ordinaryDb) : path.join(hccRoot, '.hello-cc', 'mesh.db'))
115
+ dbPath: nativeProject?.db || dshProject?.db || ordinaryDefaultDb || path.resolve(ordinaryDb),
116
+ ...(launchRootIdentity ? { initialRootIdentity: launchRootIdentity } : {})
101
117
  };
102
- if (!nativeOwner) registerProjectActivity(hookCtx);
103
118
 
104
119
  // dsh uses only its true session ID. Ordinary hooks retain environment,
105
120
  // resume, session, and terminal identity precedence.
@@ -120,6 +135,7 @@ async function cmdHook(ctx, args) {
120
135
  // Hook delivery must not migrate another workspace's registered database.
121
136
  const db = isDsh || nativeOwner ? connect(hookCtx, { migrateRegistered: false }) : connect(hookCtx);
122
137
  try {
138
+ if (!nativeOwner) registerProjectActivity(hookCtx);
123
139
  if (nativeOwner) {
124
140
  // Native delivery owns context injection and completion receipts. Hooks
125
141
  // only renew its verified owner, never turn a native binding into a
@@ -1,17 +1,23 @@
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';
8
+ import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
9
+ import { purgePinnedLegacyProjectState } from '../../process/pinned-project-state.mjs';
4
10
 
5
11
  export function createInstallCommands(deps) {
6
12
  const {
7
13
  path, fs, process, CliError, parseOpts, validateOpts, wantsHelp,
8
14
  printResult, commandPath, connect, readRuntime, runtimeRequest,
9
- runtimePath, globalRuntimePath, PRODUCT_NAME, VERSION, NPM_PACKAGE_NAME,
15
+ globalRuntimePath, PRODUCT_NAME, VERSION, NPM_PACKAGE_NAME,
10
16
  helpInstallHooks, helpShim, helpUninstall, helpUpdate,
11
17
  loadSetup, shellCommand,
12
18
 
13
19
 
14
- sameResolvedPath, writeGuidance, removeGuidanceBlocks,
20
+ sameResolvedPath, writeGuidance, removeGuidanceBlocks, spawnPinnedStateProcess,
15
21
 
16
22
  } = deps;
17
23
 
@@ -236,14 +242,48 @@ async function cmdUninstall(ctx, args) {
236
242
  if (opts.purge && !opts.yes) {
237
243
  throw new CliError('CONFIRM_REQUIRED', 'Refusing to purge project data without --yes');
238
244
  }
245
+ const selectedRoot = opts.purge ? captureSelectedCwdIdentity(ctx.root) : null;
246
+ let selectedLegacyState = null;
247
+ try {
248
+ // Resolve before changing hooks, guidance, or runtime state. An unsafe root
249
+ // with unmigrated legacy data must not be treated as an empty private store.
250
+ let purgeStateDir = null;
251
+ let purgeIsLegacy = false;
252
+ let missingPrivateState = false;
253
+ if (opts.purge) {
254
+ try {
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
+ }
262
+ } catch (error) {
263
+ if (projectStateDir(ctx.root) !== privateProjectStateDir(ctx.root)) throw error;
264
+ const recoverablePending = error?.code === 'STATE_MIGRATION_REQUIRED' &&
265
+ inspectPrivateProjectAuthority(ctx.root) === 'pending';
266
+ if (!['STATE_AUTHORITY_MISSING', 'STATE_PURGE_INCOMPLETE'].includes(error?.code) &&
267
+ !recoverablePending) throw error;
268
+ purgeStateDir = privateProjectStateDir(ctx.root);
269
+ missingPrivateState = true;
270
+ }
271
+ }
239
272
 
240
273
  const lines = [];
241
274
 
242
275
  let runtime = null;
243
- try {
244
- runtime = readRuntime(ctx);
245
- } catch (err) {
246
- if (!(err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING')) throw err;
276
+ let runtimePointer = null;
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');
282
+ try {
283
+ runtime = readRuntime(ctx);
284
+ } catch (err) {
285
+ if (!(err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING')) throw err;
286
+ }
247
287
  }
248
288
  if (runtime) {
249
289
  try {
@@ -251,7 +291,7 @@ async function cmdUninstall(ctx, args) {
251
291
  lines.push('runtime stopped');
252
292
  } catch (err) {
253
293
  if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
254
- try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
294
+ try { fs.rmSync(runtimePointer, { force: true }); } catch {}
255
295
  lines.push('stale runtime file removed');
256
296
  }
257
297
  } else {
@@ -259,6 +299,7 @@ async function cmdUninstall(ctx, args) {
259
299
  }
260
300
 
261
301
  const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims, uninstallPathEntry } = await loadSetup();
302
+ selectedRoot?.assertUnchanged();
262
303
  const claude = uninstallClaudeHooks();
263
304
  const codex = uninstallCodexHooks();
264
305
  const shims = uninstallShims();
@@ -271,11 +312,36 @@ async function cmdUninstall(ctx, args) {
271
312
  let guidance = [];
272
313
  let purged = false;
273
314
  if (opts.purge) {
274
- guidance = removeGuidanceBlocks(ctx);
315
+ guidance = removeGuidanceBlocks(ctx, { expectedBinding: selectedRoot });
275
316
  try {
276
- fs.rmSync(path.join(ctx.root, '.hello-cc'), { recursive: true, force: true });
317
+ selectedRoot.assertUnchanged();
318
+ if (!purgeIsLegacy) {
319
+ // Revalidate and delete under the same transition lock used by init
320
+ // and migration. An interrupted deletion remains marked purging.
321
+ purgeStateDir = purgePrivateProjectState(ctx.root);
322
+ } else {
323
+ // Async shutdown and hook removal happened after the initial lookup.
324
+ // Refuse a stale legacy target if project-state routing changed.
325
+ const current = secureExistingProjectStateDirectory(ctx.root);
326
+ if (path.resolve(current.stateDir) !== path.resolve(purgeStateDir)) {
327
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state route changed during uninstall');
328
+ }
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
+ }
335
+ }
277
336
  purged = true;
278
- lines.push(`project data removed: ${path.join(ctx.root, '.hello-cc')}`);
337
+ lines.push(`project data removed: ${purgeStateDir}`);
338
+ const legacyDir = legacyProjectStateDir(ctx.root);
339
+ if (path.resolve(legacyDir) !== path.resolve(purgeStateDir) && fs.existsSync(legacyDir)) {
340
+ lines.push(`legacy project data kept: ${legacyDir}`);
341
+ lines.push('private authority kept; future use requires an explicit offline migration of the legacy source');
342
+ } else if (path.resolve(legacyDir) !== path.resolve(purgeStateDir)) {
343
+ lines.push('private authority kept; a fresh init can now create new private state');
344
+ }
279
345
  } catch (err) {
280
346
  throw new CliError('PURGE_FAILED', `Could not remove .hello-cc: ${err.message}`);
281
347
  }
@@ -285,6 +351,10 @@ async function cmdUninstall(ctx, args) {
285
351
  }
286
352
 
287
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
+ }
288
358
  }
289
359
 
290
360
  // ─── hcc scan ────────────────────────────────────────────────────────────────
@@ -1,4 +1,6 @@
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 {
@@ -54,7 +56,24 @@ export function createMiscCommands(deps) {
54
56
 
55
57
  async function cmdDown(ctx, args) {
56
58
  if (args[0] === '--help' || args[0] === '-h') return helpDown();
57
- const pointerFiles = [runtimePath(ctx), globalRuntimePath()];
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()];
58
77
  let runtime;
59
78
  try {
60
79
  runtime = readRuntime(ctx);
@@ -71,13 +90,16 @@ export function createMiscCommands(deps) {
71
90
  await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, runtime);
72
91
  } catch (err) {
73
92
  if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
74
- if (!stopEvidence) throw err;
93
+ if (!stopEvidence) throw stopFailure(err, 'evidence_unavailable');
94
+ const confirmationStartedAt = performance.now();
75
95
  const stopped = await waitForProcessIdentityExit(stopEvidence.owner, {
76
96
  timeoutMs: 5_000,
77
97
  intervalMs: 25
78
98
  });
79
- if (stopped.state !== 'dead') throw err;
99
+ const confirmationMs = elapsedSince(confirmationStartedAt);
100
+ if (stopped.state !== 'dead') throw stopFailure(err, 'exit_unconfirmed', stopped.state, confirmationMs);
80
101
  let cleanup;
102
+ let cleanupPhase = 'pointer_cleanup_failed';
81
103
  try {
82
104
  cleanup = withFileLock(stopEvidence.source, (file) => {
83
105
  let current;
@@ -86,7 +108,10 @@ export function createMiscCommands(deps) {
86
108
  if (error.code === 'ENOENT') return { reclaimed: 0, blocked: false };
87
109
  throw error;
88
110
  }
89
- if (!sameStopPointer(stopEvidence.pointer, current)) throw err;
111
+ if (!sameStopPointer(stopEvidence.pointer, current)) {
112
+ cleanupPhase = 'pointer_changed';
113
+ throw err;
114
+ }
90
115
  // Hold the pointer's existing lock across comparison and reclamation.
91
116
  // A replacement owner must never be removed by this stop request.
92
117
  return reclaimRuntimePointerFiles([file], {
@@ -94,8 +119,8 @@ export function createMiscCommands(deps) {
94
119
  withLock: (_file, operation) => operation(file)
95
120
  });
96
121
  }, { createParent: false });
97
- } catch { throw err; }
98
- if (cleanup.blocked || cleanup.reclaimed > 1) throw err;
122
+ } catch { throw stopFailure(err, cleanupPhase, 'dead', confirmationMs); }
123
+ if (cleanup.blocked || cleanup.reclaimed > 1) throw stopFailure(err, 'pointer_cleanup_failed', 'dead', confirmationMs);
99
124
  printResult(ctx, { runtime: stopEvidence.source }, () => cleanup.reclaimed === 1
100
125
  ? `${PRODUCT_NAME} stale runtime pointer removed`
101
126
  : `${PRODUCT_NAME} runtime stopped`);
@@ -103,16 +128,18 @@ export function createMiscCommands(deps) {
103
128
  }
104
129
  if (runtime.source !== 'env') {
105
130
  const processIdentity = runtime.process_identity || runtime.processIdentity || runtime.pid;
131
+ const confirmationStartedAt = performance.now();
106
132
  const stopped = await waitForProcessIdentityExit(processIdentity, {
107
133
  timeoutMs: 5_000,
108
134
  intervalMs: 25
109
135
  });
136
+ const confirmationMs = elapsedSince(confirmationStartedAt);
110
137
  if (stopped.state !== 'dead') {
111
- throw new CliError(
138
+ throw stopFailure(new CliError(
112
139
  'RUNTIME_STOP_TIMEOUT',
113
140
  `Runtime accepted the stop request, but process ${runtime.pid || 'unknown'} did not exit within 5s.`,
114
- { pid: runtime.pid || null, state: stopped.state }
115
- );
141
+ { pid: runtime.pid || null }
142
+ ), 'exit_unconfirmed', stopped.state, confirmationMs);
116
143
  }
117
144
  }
118
145
  printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
@@ -1,12 +1,9 @@
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';
6
+ import { ensureNativeRuntime } from '../../runtime/native/launcher.mjs';
10
7
  import { nativeRequest } from '../../runtime/native/client.mjs';
11
8
  import { startNativeService } from '../../runtime/native/service.mjs';
12
9
 
@@ -29,37 +26,6 @@ dsh workers use the ACP profile, not its Web-service terminal.`);
29
26
  }
30
27
 
31
28
  export function createNativeCommands(deps) {
32
- async function up(ctx) {
33
- if (readNativePointer(ctx)) {
34
- try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 2000 }); }
35
- catch (error) {
36
- if (error.code !== 'NATIVE_RUNTIME_OFFLINE') throw error;
37
- }
38
- }
39
- 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');
44
- }
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
- let failed;
50
- child.on('error', (error) => { failed = error; });
51
- child.unref();
52
- const deadline = Date.now() + 10000;
53
- while (Date.now() < deadline) {
54
- if (failed) throw new CliError('NATIVE_RUNTIME_START_FAILED', failed.message);
55
- if (readNativePointer(ctx)) {
56
- try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 1000 }); } catch {}
57
- }
58
- await delay(100);
59
- }
60
- throw new CliError('NATIVE_RUNTIME_START_FAILED', `Native runtime did not become ready. Inspect ${paths.log}`);
61
- }
62
-
63
29
  async function cmdNative(ctx, args) {
64
30
  const command = args[0];
65
31
  if (!command || wantsHelp(args)) return nativeHelp();
@@ -79,7 +45,7 @@ export function createNativeCommands(deps) {
79
45
  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');
80
46
  if (command === 'send' && !opts.body?.trim()) throw new CliError('BAD_ARGS', 'Missing --body');
81
47
  let result;
82
- if (command === 'up') result = await up(ctx);
48
+ if (command === 'up') result = await ensureNativeRuntime(ctx);
83
49
  else if (command === 'serve') {
84
50
  const service = await startNativeService(ctx, deps);
85
51
  const shutdown = () => { void service.shutdown().catch((error) => console.error(JSON.stringify({ code: error.code, message: error.message }))); };
@@ -87,7 +53,7 @@ export function createNativeCommands(deps) {
87
53
  process.once('SIGINT', shutdown);
88
54
  return;
89
55
  } else if (command === 'start') {
90
- await up(ctx);
56
+ await ensureNativeRuntime(ctx);
91
57
  result = await nativeRequest(ctx, 'POST', '/workers', { peer: opts.peer, provider: opts.provider,
92
58
  cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume });
93
59
  } else if (command === 'send') {
@@ -113,7 +79,7 @@ export function createNativeCommands(deps) {
113
79
  const data = fs.readFileSync(path.resolve(ctx.cwd, opts['response-file']), 'utf8');
114
80
  if (data.length > 100000) throw new CliError('BAD_ARGS', 'Response file exceeds the limit');
115
81
  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');
82
+ 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
83
  }
118
84
  const request = pending[0];
119
85
  result = await nativeRequest(ctx, 'POST', '/respond', { peer: opts.peer, generation: state.generation,
@@ -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) {
@@ -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);
@@ -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
+ }