@logicseek/hello-cc 1.1.0-rc.1 → 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 (138) hide show
  1. package/CHANGELOG.md +190 -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 +95 -4
  12. package/docs/native.zh-CN.md +70 -1
  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 +3 -44
  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 +24 -6
  39. package/lib/integrations/native/codex.mjs +38 -7
  40. package/lib/integrations/native/dsh-acp.mjs +67 -24
  41. package/lib/integrations/native/index.mjs +4 -1
  42. package/lib/integrations/native/jsonrpc.mjs +37 -5
  43. package/lib/integrations/native/telemetry.mjs +30 -0
  44. package/lib/process/identity.mjs +67 -28
  45. package/lib/process/pinned-cwd.mjs +302 -0
  46. package/lib/process/pinned-guidance.mjs +138 -0
  47. package/lib/process/pinned-project-state.mjs +105 -0
  48. package/lib/process/selected-cwd-identity.mjs +95 -0
  49. package/lib/runtime/buffer-evidence.mjs +7 -1
  50. package/lib/runtime/client.mjs +18 -1
  51. package/lib/runtime/native/client.mjs +4 -1
  52. package/lib/runtime/native/history.mjs +140 -0
  53. package/lib/runtime/native/launcher.mjs +106 -0
  54. package/lib/runtime/native/service.mjs +160 -27
  55. package/lib/runtime/native/store.mjs +51 -21
  56. package/lib/runtime/project-path.mjs +24 -5
  57. package/lib/runtime/projects.mjs +3 -0
  58. package/lib/runtime/state.mjs +61 -26
  59. package/lib/shared/file-lock.mjs +62 -17
  60. package/lib/shared/redact.mjs +2 -1
  61. package/lib/shared/socket-lock-worker.mjs +15 -6
  62. package/lib/web/agent-defaults.mjs +110 -0
  63. package/lib/web/auto-attach.mjs +3 -2
  64. package/lib/web/browser/contracts.d.ts +10 -0
  65. package/lib/web/browser/core.mjs +1997 -0
  66. package/lib/web/browser/project-data.mjs +158 -0
  67. package/lib/web/browser/session-sync.mjs +82 -0
  68. package/lib/web/browser-assets.mjs +19 -0
  69. package/lib/web/buffer-gc-runtime.mjs +2 -2
  70. package/lib/web/codex-app-server.mjs +104 -33
  71. package/lib/web/codex-sessions.mjs +60 -11
  72. package/lib/web/context-files.mjs +82 -0
  73. package/lib/web/cookie-auth.mjs +51 -3
  74. package/lib/web/external-sessions.mjs +22 -4
  75. package/lib/web/http-routes.mjs +236 -23
  76. package/lib/web/http.mjs +50 -4
  77. package/lib/web/liveness-reaper.mjs +2 -2
  78. package/lib/web/native-sessions.mjs +322 -13
  79. package/lib/web/project-contexts.mjs +235 -42
  80. package/lib/web/project-file-write.mjs +249 -0
  81. package/lib/web/project-files.mjs +268 -0
  82. package/lib/web/pty-sessions.mjs +11 -3
  83. package/lib/web/runtime-main.mjs +134 -82
  84. package/lib/web/session-serialize.mjs +87 -8
  85. package/lib/web/session-state-sync.mjs +98 -0
  86. package/lib/web/shutdown-diagnostics.mjs +38 -0
  87. package/lib/web/shutdown-response.mjs +21 -0
  88. package/lib/web/shutdown-state.mjs +91 -0
  89. package/lib/web/startup.mjs +76 -27
  90. package/lib/web/tmux-client-snapshot.mjs +85 -0
  91. package/lib/web/tmux-sessions.mjs +71 -7
  92. package/lib/web/tmux-stream.mjs +36 -8
  93. package/lib/web/ui-agent-defaults.mjs +149 -0
  94. package/lib/web/ui-agent-start.mjs +330 -0
  95. package/lib/web/ui-codex-account.mjs +81 -0
  96. package/lib/web/ui-codex.mjs +81 -73
  97. package/lib/web/ui-command-palette.mjs +21 -5
  98. package/lib/web/ui-file-writes.mjs +220 -0
  99. package/lib/web/ui-files.mjs +231 -0
  100. package/lib/web/ui-handoff.mjs +10 -2
  101. package/lib/web/ui-history.mjs +175 -105
  102. package/lib/web/ui-i18n.mjs +280 -8
  103. package/lib/web/ui-interactions.mjs +6 -4
  104. package/lib/web/ui-native-timeline.mjs +326 -0
  105. package/lib/web/ui-native.mjs +93 -49
  106. package/lib/web/ui-review.mjs +5 -3
  107. package/lib/web/ui-safe-markdown.mjs +79 -0
  108. package/lib/web/ui-session-tools.mjs +224 -0
  109. package/lib/web/ui-template.mjs +27 -1967
  110. package/lib/web/ui-terminal-find.mjs +5 -3
  111. package/lib/web/ui-workbench.mjs +5 -3
  112. package/lib/web/ui-workspace.mjs +3 -0
  113. package/package.json +8 -2
  114. package/scripts/benchmark-session-sync.mjs +53 -0
  115. package/scripts/codex-protocol.mjs +88 -0
  116. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  117. package/scripts/dsh-package-provenance.mjs +118 -0
  118. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  119. package/scripts/native-fixture-root.mjs +49 -0
  120. package/scripts/native-installed-acceptance.mjs +204 -6
  121. package/scripts/native-stability-checks.mjs +40 -0
  122. package/scripts/regression-cookie-expiry.mjs +70 -0
  123. package/scripts/regression-http-transport.mjs +9 -0
  124. package/scripts/regression-terminal-marker.mjs +95 -0
  125. package/scripts/regression.mjs +637 -186
  126. package/scripts/shim-reentry-probe.mjs +105 -0
  127. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  128. package/scripts/web-codex-account-acceptance.mjs +178 -0
  129. package/scripts/web-file-preview-checks.mjs +302 -0
  130. package/scripts/web-mcp-form-acceptance.mjs +22 -7
  131. package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
  132. package/scripts/web-native-handoff-checks.mjs +128 -0
  133. package/scripts/web-native-interaction-acceptance.mjs +152 -18
  134. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  135. package/scripts/web-native-model-acceptance.mjs +22 -7
  136. package/scripts/web-session-tools-checks.mjs +112 -0
  137. package/scripts/web-workbench-acceptance.mjs +435 -0
  138. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -5,17 +5,19 @@ import { secureExistingProjectStateDirectory } from '../../runtime/project-path.
5
5
  import { legacyProjectStateDir, projectStateDir } from '../../runtime/paths.mjs';
6
6
  import { privateProjectStateDir, inspectPrivateProjectAuthority,
7
7
  purgePrivateProjectState } from '../../runtime/private-state.mjs';
8
+ import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
9
+ import { purgePinnedLegacyProjectState } from '../../process/pinned-project-state.mjs';
8
10
 
9
11
  export function createInstallCommands(deps) {
10
12
  const {
11
13
  path, fs, process, CliError, parseOpts, validateOpts, wantsHelp,
12
14
  printResult, commandPath, connect, readRuntime, runtimeRequest,
13
- runtimePath, globalRuntimePath, PRODUCT_NAME, VERSION, NPM_PACKAGE_NAME,
15
+ globalRuntimePath, PRODUCT_NAME, VERSION, NPM_PACKAGE_NAME,
14
16
  helpInstallHooks, helpShim, helpUninstall, helpUpdate,
15
17
  loadSetup, shellCommand,
16
18
 
17
19
 
18
- sameResolvedPath, writeGuidance, removeGuidanceBlocks,
20
+ sameResolvedPath, writeGuidance, removeGuidanceBlocks, spawnPinnedStateProcess,
19
21
 
20
22
  } = deps;
21
23
 
@@ -240,13 +242,23 @@ async function cmdUninstall(ctx, args) {
240
242
  if (opts.purge && !opts.yes) {
241
243
  throw new CliError('CONFIRM_REQUIRED', 'Refusing to purge project data without --yes');
242
244
  }
245
+ const selectedRoot = opts.purge ? captureSelectedCwdIdentity(ctx.root) : null;
246
+ let selectedLegacyState = null;
247
+ try {
243
248
  // Resolve before changing hooks, guidance, or runtime state. An unsafe root
244
249
  // with unmigrated legacy data must not be treated as an empty private store.
245
250
  let purgeStateDir = null;
251
+ let purgeIsLegacy = false;
246
252
  let missingPrivateState = false;
247
253
  if (opts.purge) {
248
254
  try {
249
- purgeStateDir = secureExistingProjectStateDirectory(ctx.root).stateDir;
255
+ const resolved = secureExistingProjectStateDirectory(ctx.root);
256
+ purgeStateDir = resolved.stateDir;
257
+ purgeIsLegacy = path.resolve(purgeStateDir) === path.join(selectedRoot.canonical, '.hello-cc');
258
+ if (purgeIsLegacy && resolved.exists) {
259
+ selectedLegacyState = captureSelectedCwdIdentity(purgeStateDir);
260
+ selectedRoot.assertUnchanged();
261
+ }
250
262
  } catch (error) {
251
263
  if (projectStateDir(ctx.root) !== privateProjectStateDir(ctx.root)) throw error;
252
264
  const recoverablePending = error?.code === 'STATE_MIGRATION_REQUIRED' &&
@@ -261,7 +273,12 @@ async function cmdUninstall(ctx, args) {
261
273
  const lines = [];
262
274
 
263
275
  let runtime = null;
276
+ let runtimePointer = null;
264
277
  if (!missingPrivateState) {
278
+ // Keep the validated state route fixed across the asynchronous stop probe.
279
+ // Re-deriving it afterwards could remove another project's pointer if a
280
+ // user-supplied root alias is retargeted in the meantime.
281
+ runtimePointer = path.join(secureExistingProjectStateDirectory(ctx.root).stateDir, 'runtime.json');
265
282
  try {
266
283
  runtime = readRuntime(ctx);
267
284
  } catch (err) {
@@ -274,7 +291,7 @@ async function cmdUninstall(ctx, args) {
274
291
  lines.push('runtime stopped');
275
292
  } catch (err) {
276
293
  if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
277
- try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
294
+ try { fs.rmSync(runtimePointer, { force: true }); } catch {}
278
295
  lines.push('stale runtime file removed');
279
296
  }
280
297
  } else {
@@ -282,6 +299,7 @@ async function cmdUninstall(ctx, args) {
282
299
  }
283
300
 
284
301
  const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims, uninstallPathEntry } = await loadSetup();
302
+ selectedRoot?.assertUnchanged();
285
303
  const claude = uninstallClaudeHooks();
286
304
  const codex = uninstallCodexHooks();
287
305
  const shims = uninstallShims();
@@ -294,20 +312,26 @@ async function cmdUninstall(ctx, args) {
294
312
  let guidance = [];
295
313
  let purged = false;
296
314
  if (opts.purge) {
297
- guidance = removeGuidanceBlocks(ctx);
315
+ guidance = removeGuidanceBlocks(ctx, { expectedBinding: selectedRoot });
298
316
  try {
299
- if (path.resolve(purgeStateDir) === path.resolve(privateProjectStateDir(ctx.root))) {
317
+ selectedRoot.assertUnchanged();
318
+ if (!purgeIsLegacy) {
300
319
  // Revalidate and delete under the same transition lock used by init
301
320
  // and migration. An interrupted deletion remains marked purging.
302
321
  purgeStateDir = purgePrivateProjectState(ctx.root);
303
322
  } else {
304
323
  // Async shutdown and hook removal happened after the initial lookup.
305
324
  // Refuse a stale legacy target if project-state routing changed.
306
- const current = secureExistingProjectStateDirectory(ctx.root).stateDir;
307
- if (path.resolve(current) !== path.resolve(purgeStateDir)) {
325
+ const current = secureExistingProjectStateDirectory(ctx.root);
326
+ if (path.resolve(current.stateDir) !== path.resolve(purgeStateDir)) {
308
327
  throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state route changed during uninstall');
309
328
  }
310
- fs.rmSync(purgeStateDir, { recursive: true, force: true });
329
+ if (selectedLegacyState) {
330
+ purgePinnedLegacyProjectState(ctx.root, purgeStateDir, selectedRoot, selectedLegacyState,
331
+ { spawnProcess: spawnPinnedStateProcess });
332
+ } else if (current.exists) {
333
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state appeared during uninstall');
334
+ }
311
335
  }
312
336
  purged = true;
313
337
  lines.push(`project data removed: ${purgeStateDir}`);
@@ -327,6 +351,10 @@ async function cmdUninstall(ctx, args) {
327
351
  }
328
352
 
329
353
  printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, path_entry: pathEntry, purge: purged, guidance }, () => lines.join('\n'));
354
+ } finally {
355
+ selectedLegacyState?.release();
356
+ selectedRoot?.release();
357
+ }
330
358
  }
331
359
 
332
360
  // ─── hcc scan ────────────────────────────────────────────────────────────────
@@ -2,6 +2,7 @@
2
2
  // Factory pattern with injected deps.
3
3
 
4
4
  import { readTx } from '../../db/schema.mjs';
5
+ import { isPeerEvidenceCompatibilityProtected } from '../../core/peers/evidence.mjs';
5
6
  import {
6
7
  findRequestedLock,
7
8
  upsertRequestedLock,
@@ -101,7 +102,8 @@ export function createLockCommands(deps) {
101
102
  !(Number(row.expires_at) <= t && evidenceByOwner.get(row.owner)?.state === 'dead'))
102
103
  : subject.locks.filter((row) =>
103
104
  Number(row.expires_at) > t ||
104
- evidenceByOwner.get(row.owner)?.state === 'live'
105
+ evidenceByOwner.get(row.owner)?.state === 'live' ||
106
+ isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(row.owner))
105
107
  );
106
108
  const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
107
109
  if (conflict) {
@@ -1,17 +1,79 @@
1
1
  // Small utility commands extracted from bin/hcc.mjs: down, find-root, which-real.
2
+ import { performance } from 'node:perf_hooks';
3
+ import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
2
4
 
3
5
  export function createMiscCommands(deps) {
4
6
  const {
5
- path, process, CliError, parseOpts, printResult,
7
+ path, fs, process, CliError, parseOpts, printResult,
6
8
  readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
7
9
  reclaimRuntimePointerFiles, helpDown, loadSetup,
8
- waitForProcessIdentityExit,
10
+ waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity, withFileLock,
9
11
  PRODUCT_NAME
10
12
  } = deps;
11
13
 
14
+ function sameStopPointer(left, right) {
15
+ return left.raw === right.raw && ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs']
16
+ .every((key) => left.stat[key] === right.stat[key]);
17
+ }
18
+
19
+ function readStopPointer(file) {
20
+ const stat = fs.lstatSync(file);
21
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('invalid runtime pointer');
22
+ const raw = fs.readFileSync(file, 'utf8');
23
+ const current = fs.lstatSync(file);
24
+ if (current.isSymbolicLink() || !current.isFile() ||
25
+ !sameStopPointer({ raw, stat }, { raw, stat: current })) {
26
+ throw new Error('runtime pointer changed');
27
+ }
28
+ return { raw, stat };
29
+ }
30
+
31
+ function stopOwnerEvidence(runtime, pointerFiles) {
32
+ try {
33
+ if (runtime.source === 'env') return null;
34
+ const source = pointerFiles.find((file) => path.resolve(file) === path.resolve(runtime.source || ''));
35
+ const url = new URL(runtime.base_url);
36
+ if (!source || !['http:', 'https:'].includes(url.protocol) ||
37
+ !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) return null;
38
+ const owner = runtime.process_identity || runtime.processIdentity;
39
+ if (Object.hasOwn(runtime, 'process_identity') && Object.hasOwn(runtime, 'processIdentity')) return null;
40
+ if (owner?.pid !== Number(runtime.pid) || compareProcessIdentity(owner, owner) !== 'live') return null;
41
+ const observed = inspectProcessIdentity(owner.pid);
42
+ if (observed.state !== 'live' || compareProcessIdentity(owner, observed.identity) !== 'live' ||
43
+ owner.commandHash !== observed.identity.commandHash) return null;
44
+ const pointer = readStopPointer(source);
45
+ const recorded = JSON.parse(pointer.raw);
46
+ if (Object.hasOwn(recorded, 'process_identity') && Object.hasOwn(recorded, 'processIdentity')) return null;
47
+ const recordedOwner = recorded.process_identity || recorded.processIdentity;
48
+ if (Number(recorded.pid) !== owner.pid ||
49
+ compareProcessIdentity(owner, recordedOwner) !== 'live' || owner.commandHash !== recordedOwner.commandHash ||
50
+ ['base_url', 'token', 'tls_ca_file'].some((key) => recorded[key] !== runtime[key])) return null;
51
+ return { source, owner, pointer };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
12
57
  async function cmdDown(ctx, args) {
13
58
  if (args[0] === '--help' || args[0] === '-h') return helpDown();
14
- const 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()];
15
77
  let runtime;
16
78
  try {
17
79
  runtime = readRuntime(ctx);
@@ -23,31 +85,61 @@ export function createMiscCommands(deps) {
23
85
  `${PRODUCT_NAME} stale runtime pointer removed${result.pointers === 1 ? '' : 's'}`);
24
86
  return;
25
87
  }
88
+ const stopEvidence = stopOwnerEvidence(runtime, pointerFiles);
26
89
  try {
27
90
  await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, runtime);
28
91
  } catch (err) {
29
92
  if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
30
- if (runtime.source === 'env') throw err;
31
- const source = path.resolve(String(runtime.source || ''));
32
- const localSource = pointerFiles.find((file) => path.resolve(file) === source);
33
- if (!localSource) throw err;
34
- const cleanup = reclaimRuntimePointerFiles([localSource], { reclaimUnknown: false });
35
- if (cleanup.reclaimed !== 1 || cleanup.blocked) throw err;
36
- printResult(ctx, { runtime: localSource }, () => `${PRODUCT_NAME} stale runtime pointer removed`);
93
+ if (!stopEvidence) throw stopFailure(err, 'evidence_unavailable');
94
+ const confirmationStartedAt = performance.now();
95
+ const stopped = await waitForProcessIdentityExit(stopEvidence.owner, {
96
+ timeoutMs: 5_000,
97
+ intervalMs: 25
98
+ });
99
+ const confirmationMs = elapsedSince(confirmationStartedAt);
100
+ if (stopped.state !== 'dead') throw stopFailure(err, 'exit_unconfirmed', stopped.state, confirmationMs);
101
+ let cleanup;
102
+ let cleanupPhase = 'pointer_cleanup_failed';
103
+ try {
104
+ cleanup = withFileLock(stopEvidence.source, (file) => {
105
+ let current;
106
+ try { current = readStopPointer(file); }
107
+ catch (error) {
108
+ if (error.code === 'ENOENT') return { reclaimed: 0, blocked: false };
109
+ throw error;
110
+ }
111
+ if (!sameStopPointer(stopEvidence.pointer, current)) {
112
+ cleanupPhase = 'pointer_changed';
113
+ throw err;
114
+ }
115
+ // Hold the pointer's existing lock across comparison and reclamation.
116
+ // A replacement owner must never be removed by this stop request.
117
+ return reclaimRuntimePointerFiles([file], {
118
+ reclaimUnknown: false,
119
+ withLock: (_file, operation) => operation(file)
120
+ });
121
+ }, { createParent: false });
122
+ } catch { throw stopFailure(err, cleanupPhase, 'dead', confirmationMs); }
123
+ if (cleanup.blocked || cleanup.reclaimed > 1) throw stopFailure(err, 'pointer_cleanup_failed', 'dead', confirmationMs);
124
+ printResult(ctx, { runtime: stopEvidence.source }, () => cleanup.reclaimed === 1
125
+ ? `${PRODUCT_NAME} stale runtime pointer removed`
126
+ : `${PRODUCT_NAME} runtime stopped`);
37
127
  return;
38
128
  }
39
129
  if (runtime.source !== 'env') {
40
130
  const processIdentity = runtime.process_identity || runtime.processIdentity || runtime.pid;
131
+ const confirmationStartedAt = performance.now();
41
132
  const stopped = await waitForProcessIdentityExit(processIdentity, {
42
133
  timeoutMs: 5_000,
43
134
  intervalMs: 25
44
135
  });
136
+ const confirmationMs = elapsedSince(confirmationStartedAt);
45
137
  if (stopped.state !== 'dead') {
46
- throw new CliError(
138
+ throw stopFailure(new CliError(
47
139
  'RUNTIME_STOP_TIMEOUT',
48
140
  `Runtime accepted the stop request, but process ${runtime.pid || 'unknown'} did not exit within 5s.`,
49
- { pid: runtime.pid || null, state: stopped.state }
50
- );
141
+ { pid: runtime.pid || null }
142
+ ), 'exit_unconfirmed', stopped.state, confirmationMs);
51
143
  }
52
144
  }
53
145
  printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
@@ -1,13 +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';
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
 
@@ -30,43 +26,6 @@ dsh workers use the ACP profile, not its Web-service terminal.`);
30
26
  }
31
27
 
32
28
  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);
55
- }
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);
66
- }
67
- throw new CliError('NATIVE_RUNTIME_START_FAILED', `Native runtime did not become ready. Inspect ${paths.log}`);
68
- }
69
-
70
29
  async function cmdNative(ctx, args) {
71
30
  const command = args[0];
72
31
  if (!command || wantsHelp(args)) return nativeHelp();
@@ -86,7 +45,7 @@ export function createNativeCommands(deps) {
86
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');
87
46
  if (command === 'send' && !opts.body?.trim()) throw new CliError('BAD_ARGS', 'Missing --body');
88
47
  let result;
89
- if (command === 'up') result = await up(ctx);
48
+ if (command === 'up') result = await ensureNativeRuntime(ctx);
90
49
  else if (command === 'serve') {
91
50
  const service = await startNativeService(ctx, deps);
92
51
  const shutdown = () => { void service.shutdown().catch((error) => console.error(JSON.stringify({ code: error.code, message: error.message }))); };
@@ -94,7 +53,7 @@ export function createNativeCommands(deps) {
94
53
  process.once('SIGINT', shutdown);
95
54
  return;
96
55
  } else if (command === 'start') {
97
- await up(ctx);
56
+ await ensureNativeRuntime(ctx);
98
57
  result = await nativeRequest(ctx, 'POST', '/workers', { peer: opts.peer, provider: opts.provider,
99
58
  cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume });
100
59
  } else if (command === 'send') {
@@ -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' };