@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
@@ -2,12 +2,31 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { CliError } from '../shared/errors.mjs';
5
+ import { projectStateDir, legacyProjectStateDir } from './paths.mjs';
6
+ import { ensurePrivateProjectStateDir, inspectPrivateProjectAuthority } from './private-state.mjs';
7
+ import { unsafeDirectoryAcl } from './project-trust.mjs';
5
8
 
6
9
  function pathIsWithin(parent, candidate) {
7
10
  const relative = path.relative(parent, candidate);
8
11
  return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
9
12
  }
10
13
 
14
+ function canonicalCandidateFromExistingParent(candidate) {
15
+ let parent = path.dirname(candidate);
16
+ const missing = [];
17
+ for (;;) {
18
+ try {
19
+ return path.join(fs.realpathSync(parent), ...missing.reverse(), path.basename(candidate));
20
+ } catch (error) {
21
+ if (error?.code !== 'ENOENT') return null;
22
+ const next = path.dirname(parent);
23
+ if (next === parent) return null;
24
+ missing.push(path.basename(parent));
25
+ parent = next;
26
+ }
27
+ }
28
+ }
29
+
11
30
  function projectPathForbidden(message) {
12
31
  return new CliError('PROJECT_PATH_FORBIDDEN', message);
13
32
  }
@@ -23,7 +42,7 @@ function canonicalProjectRoot(root) {
23
42
  }
24
43
  }
25
44
 
26
- function privateDirectory(directory, description) {
45
+ function privateDirectory(directory, description, { tighten = true } = {}) {
27
46
  let stat;
28
47
  try { stat = fs.lstatSync(directory); }
29
48
  catch { throw projectPathForbidden(`Cannot inspect ${description}: ${directory}`); }
@@ -37,11 +56,15 @@ function privateDirectory(directory, description) {
37
56
  fd = fs.openSync(directory, fs.constants.O_RDONLY |
38
57
  (fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
39
58
  const opened = fs.fstatSync(fd);
40
- if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
59
+ if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino ||
60
+ unsafeDirectoryAcl(directory, opened)) {
41
61
  throw projectPathForbidden(`${description} changed during validation: ${directory}`);
42
62
  }
43
63
  if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) {
44
- fs.fchmodSync(fd, 0o700);
64
+ if (tighten) fs.fchmodSync(fd, 0o700);
65
+ else if ((opened.mode & 0o022) !== 0) {
66
+ throw projectPathForbidden(`${description} is writable by another user: ${directory}`);
67
+ }
45
68
  }
46
69
  const current = fs.lstatSync(directory);
47
70
  if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
@@ -57,7 +80,47 @@ function privateDirectory(directory, description) {
57
80
  }
58
81
 
59
82
  function ensureStateDirectory(root, createStateDir, secureExisting = createStateDir) {
60
- const stateDir = path.join(root, '.hello-cc');
83
+ const stateDir = projectStateDir(root);
84
+ if (stateDir !== legacyProjectStateDir(root)) {
85
+ const authority = inspectPrivateProjectAuthority(root);
86
+ let privateEntryExists = false;
87
+ try { fs.lstatSync(stateDir); privateEntryExists = true; }
88
+ catch (error) {
89
+ if (error?.code !== 'ENOENT') {
90
+ throw projectPathForbidden(`Cannot inspect private project state: ${stateDir}`);
91
+ }
92
+ }
93
+ // A crash can leave a pending marker with an empty directory or a valid
94
+ // manifest not yet promoted to fresh. Older private stores may have no
95
+ // sibling marker. Finish either binding before returning any existing
96
+ // state, including during a read-only request.
97
+ const finishBinding = privateEntryExists &&
98
+ (authority === null || authority === 'pending');
99
+ const existing = ensurePrivateProjectStateDir(root, { create: finishBinding });
100
+ if (existing) return { stateDir, exists: true };
101
+ if (authority === 'fresh' || authority === 'migrated') {
102
+ throw new CliError('STATE_AUTHORITY_MISSING',
103
+ `Previously established private project state is missing: ${stateDir}. Restore it from backup; ` +
104
+ 'if the legacy source still exists, stop all old writers and run ' +
105
+ 'hcc --root DIR migrate-state --offline --yes; otherwise run ' +
106
+ 'hcc --root DIR uninstall --purge --yes to explicitly reset the missing state.');
107
+ }
108
+ const legacy = legacyProjectStateDir(root);
109
+ let legacyStat = null;
110
+ try { legacyStat = fs.lstatSync(legacy); }
111
+ catch (error) {
112
+ if (error?.code !== 'ENOENT') throw projectPathForbidden(`Cannot inspect legacy project state: ${legacy}`);
113
+ }
114
+ if (legacyStat && (legacyStat.isSymbolicLink() || !legacyStat.isDirectory())) {
115
+ throw projectPathForbidden(`Legacy project state is not a real directory: ${legacy}`);
116
+ }
117
+ if (legacyStat) {
118
+ throw new CliError('STATE_MIGRATION_REQUIRED',
119
+ `Legacy project state requires an offline migration before use: ${legacy}`);
120
+ }
121
+ const secured = createStateDir ? ensurePrivateProjectStateDir(root, { create: true }) : null;
122
+ return { stateDir, exists: Boolean(secured) };
123
+ }
61
124
  let stateStat;
62
125
  try {
63
126
  stateStat = fs.lstatSync(stateDir);
@@ -79,9 +142,7 @@ function ensureStateDirectory(root, createStateDir, secureExisting = createState
79
142
  throw projectPathForbidden(`Project state directory must be a real directory: ${stateDir}`);
80
143
  }
81
144
  try {
82
- const canonical = secureExisting
83
- ? privateDirectory(stateDir, 'Project state directory')
84
- : fs.realpathSync(stateDir);
145
+ const canonical = privateDirectory(stateDir, 'Project state directory', { tighten: secureExisting });
85
146
  if (canonical !== stateDir || !pathIsWithin(root, canonical)) {
86
147
  throw projectPathForbidden(`Project state directory escapes its project root: ${stateDir}`);
87
148
  }
@@ -121,7 +182,7 @@ function validateDatabaseParent(stateDir, db, createParents = false) {
121
182
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
122
183
  throw projectPathForbidden(`Database parent must contain only real directories: ${current}`);
123
184
  }
124
- if (createParents && privateDirectory(current, 'Database parent directory') !== current) {
185
+ if (privateDirectory(current, 'Database parent directory', { tighten: createParents }) !== current) {
125
186
  throw projectPathForbidden(`Database parent directory changed: ${current}`);
126
187
  }
127
188
  }
@@ -164,16 +225,23 @@ export function resolveProjectDatabase({ root, db, createStateDir = false, creat
164
225
  const requestedRoot = path.resolve(String(root || ''));
165
226
  const canonicalRoot = canonicalProjectRoot(requestedRoot);
166
227
  const rawDb = path.resolve(String(db || path.join(requestedRoot, '.hello-cc', 'mesh.db')));
167
- let requestedDb = pathIsWithin(requestedRoot, rawDb)
168
- ? path.join(canonicalRoot, path.relative(requestedRoot, rawDb))
169
- : rawDb;
170
- const intendedStateDir = path.join(canonicalRoot, '.hello-cc');
228
+ const intendedStateDir = projectStateDir(canonicalRoot);
229
+ const requestedLegacyState = legacyProjectStateDir(requestedRoot);
230
+ const canonicalLegacyState = legacyProjectStateDir(canonicalRoot);
231
+ let requestedDb = pathIsWithin(requestedLegacyState, rawDb)
232
+ ? path.join(intendedStateDir, path.relative(requestedLegacyState, rawDb))
233
+ : pathIsWithin(canonicalLegacyState, rawDb)
234
+ ? path.join(intendedStateDir, path.relative(canonicalLegacyState, rawDb))
235
+ : rawDb;
171
236
  if (!pathIsWithin(intendedStateDir, requestedDb)) {
172
- try {
173
- const canonicalParent = fs.realpathSync(path.dirname(requestedDb));
174
- const canonicalCandidate = path.join(canonicalParent, path.basename(requestedDb));
175
- if (pathIsWithin(intendedStateDir, canonicalCandidate)) requestedDb = canonicalCandidate;
176
- } catch {}
237
+ // An explicit --db may be spelled through an alias of an ancestor such as
238
+ // macOS /var -> /private/var, and its nested DB parents may not exist yet.
239
+ // Canonicalize the nearest existing parent before containment checking;
240
+ // validateDatabaseParent still rejects any newly created symlink segment.
241
+ const canonicalCandidate = canonicalCandidateFromExistingParent(requestedDb);
242
+ if (canonicalCandidate && pathIsWithin(intendedStateDir, canonicalCandidate)) {
243
+ requestedDb = canonicalCandidate;
244
+ }
177
245
  }
178
246
  if (!pathIsWithin(intendedStateDir, requestedDb)) {
179
247
  throw projectPathForbidden(`Database path must live under ${intendedStateDir}`);
@@ -191,13 +259,13 @@ export function resolveProjectDatabase({ root, db, createStateDir = false, creat
191
259
  };
192
260
  }
193
261
 
194
- export function ensurePrivateProjectBufferDirectory(root, directoryName = 'bufs', { create = true } = {}) {
262
+ export function ensurePrivateProjectStateSubdirectory(root, directoryName, { create = true } = {}) {
195
263
  if (!directoryName || directoryName !== path.basename(directoryName) ||
196
264
  directoryName === '.' || directoryName === '..') {
197
265
  throw projectPathForbidden(`Invalid buffer directory name: ${directoryName}`);
198
266
  }
199
267
  const canonicalRoot = canonicalProjectRoot(root);
200
- const state = ensureStateDirectory(canonicalRoot, create, true);
268
+ const state = ensureStateDirectory(canonicalRoot, create, create);
201
269
  if (!state.exists) return null;
202
270
  const { stateDir } = state;
203
271
  const directory = path.join(stateDir, directoryName);
@@ -215,8 +283,12 @@ export function ensurePrivateProjectBufferDirectory(root, directoryName = 'bufs'
215
283
  throw projectPathForbidden(`Cannot inspect buffer directory: ${directory}`);
216
284
  }
217
285
  }
218
- if (privateDirectory(directory, 'Buffer directory') !== directory) {
286
+ if (privateDirectory(directory, 'Project state subdirectory', { tighten: create }) !== directory) {
219
287
  throw projectPathForbidden(`Buffer directory escapes project state directory: ${directory}`);
220
288
  }
221
289
  return directory;
222
290
  }
291
+
292
+ export function ensurePrivateProjectBufferDirectory(root, directoryName = 'bufs', options = {}) {
293
+ return ensurePrivateProjectStateSubdirectory(root, directoryName, options);
294
+ }
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ // A private .hello-cc is only safe behind a pathname that another UID cannot
7
+ // rename. The ownership of the leaf alone is not sufficient: a writable
8
+ // non-sticky ancestor lets another UID replace its directory entry.
9
+ const aclCache = new Map();
10
+
11
+ export function unsafeDirectoryAcl(directory, stat = null) {
12
+ let identity = stat;
13
+ try { identity ||= fs.lstatSync(directory); }
14
+ catch { return true; }
15
+ const signature = `${identity.dev}:${identity.ino}:${identity.ctimeNs ?? identity.ctimeMs}`;
16
+ const cached = aclCache.get(directory);
17
+ if (cached?.signature === signature) return cached.unsafe;
18
+ const ls = fs.existsSync('/bin/ls') ? '/bin/ls'
19
+ : fs.existsSync('/usr/bin/ls') ? '/usr/bin/ls' : null;
20
+ if (!ls) return true;
21
+ const result = spawnSync(ls, process.platform === 'darwin'
22
+ ? ['-lde', directory] : ['-ld', directory], {
23
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000,
24
+ env: { ...process.env, LC_ALL: 'C' }
25
+ });
26
+ if (result.status !== 0) return true;
27
+ const [first, ...entries] = result.stdout.trim().split('\n');
28
+ const permissions = first?.trim().split(/\s+/, 1)[0];
29
+ if (!/^[-dlcbps][rwxStTs-]{9}[+@.]?$/.test(permissions)) return true;
30
+ // A deny-only macOS ACL cannot grant another UID the ability to rebind a
31
+ // directory entry. Unknown or allow ACL entries use the private fallback.
32
+ const aclEntries = entries.filter((entry) => entry.trim().length > 0);
33
+ const unsafe = process.platform === 'darwin'
34
+ ? (aclEntries.length > 0
35
+ ? aclEntries.some((entry) => !/^\s*\d+:\s+.+\sdeny\s+/.test(entry))
36
+ : permissions[10] === '+')
37
+ : permissions[10] === '+';
38
+ if (aclCache.size >= 1024) aclCache.clear();
39
+ aclCache.set(directory, { signature, unsafe });
40
+ return unsafe;
41
+ }
42
+
43
+ export function stableProjectStateRoot(root) {
44
+ if (typeof process.getuid !== 'function') return null;
45
+ const uid = process.getuid();
46
+ let canonical;
47
+ try { canonical = fs.realpathSync.native(path.resolve(root)); }
48
+ catch { return null; }
49
+ const parts = [];
50
+ for (let current = canonical;; current = path.dirname(current)) {
51
+ parts.push(current);
52
+ if (path.dirname(current) === current) break;
53
+ }
54
+ parts.reverse();
55
+ let parent = null;
56
+ for (const component of parts) {
57
+ let stat;
58
+ try { stat = fs.lstatSync(component); }
59
+ catch { return null; }
60
+ if (!stat.isDirectory() || stat.isSymbolicLink() ||
61
+ (stat.uid !== uid && stat.uid !== 0) || unsafeDirectoryAcl(component, stat)) return null;
62
+ if (parent) {
63
+ const parentMode = parent.mode & 0o7777;
64
+ if ((parentMode & 0o022) !== 0 &&
65
+ ((parentMode & 0o1000) === 0 || (stat.uid !== uid && stat.uid !== 0))) {
66
+ return null;
67
+ }
68
+ }
69
+ parent = stat;
70
+ }
71
+ // A cross-UID-writable root can have its .hello-cc entry replaced. Keep its
72
+ // state under the user's private state directory even when root is sticky.
73
+ if ((parent.mode & 0o022) !== 0) return null;
74
+ // A stable but read-only server directory (for example /etc) cannot host a
75
+ // new project-local state directory. It remains selectable via private state.
76
+ try { fs.accessSync(canonical, fs.constants.W_OK | fs.constants.X_OK); }
77
+ catch { return null; }
78
+ return canonical;
79
+ }
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { projectDbPath, projectRegistryPath } from './paths.mjs';
3
+ import { projectDbPath, projectRegistryPath, projectStateDir } from './paths.mjs';
4
4
  import { withFileLock } from '../shared/file-lock.mjs';
5
5
  import { writeJsonSafe } from '../shared/json-file.mjs';
6
+ import { assertSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
6
7
 
7
8
  const ROOT_EXISTS = Symbol('projectRootExists');
8
9
  const ROOT_WAS_CANONICAL = Symbol('projectRootWasCanonical');
@@ -101,9 +102,12 @@ function canonicalProjectDb(value, rawRoot, rootInfo, { strict = false } = {}) {
101
102
  const db = pathWithin(rawRoot, rawDb)
102
103
  ? path.resolve(rootInfo.root, relativeToRawRoot)
103
104
  : rawDb;
105
+ const privateState = projectStateDir(rootInfo.root);
106
+ const allowed = (candidate) => pathWithin(rootInfo.root, candidate) ||
107
+ (privateState !== path.join(rootInfo.root, '.hello-cc') && pathWithin(privateState, candidate));
104
108
 
105
109
  if (!rootInfo.exists) {
106
- return pathWithin(rootInfo.root, db)
110
+ return allowed(db)
107
111
  ? db
108
112
  : reject(`project DB is outside the project root: ${rawDb}`);
109
113
  }
@@ -145,7 +149,7 @@ function canonicalProjectDb(value, rawRoot, rootInfo, { strict = false } = {}) {
145
149
  const canonicalDb = dbExists
146
150
  ? canonicalAncestor
147
151
  : path.resolve(canonicalAncestor, path.relative(ancestor, db));
148
- if (!pathWithin(rootInfo.root, canonicalDb)) {
152
+ if (!allowed(canonicalDb)) {
149
153
  return reject(`project DB is outside the project root: ${rawDb}`);
150
154
  }
151
155
  return canonicalDb;
@@ -302,6 +306,7 @@ function updateProjectRegistry(candidate, lockOptions = {}) {
302
306
  }
303
307
 
304
308
  export function registerProject(ctx, lockOptions = {}) {
309
+ if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
305
310
  const candidate = projectRecord(ctx);
306
311
  if (!candidate) throw new TypeError('project root is required');
307
312
  // xx-01: the throttle check lives inside the lock (canonical-domain safe, see
@@ -311,6 +316,7 @@ export function registerProject(ctx, lockOptions = {}) {
311
316
  }
312
317
 
313
318
  export function registerProjectActivity(ctx) {
319
+ if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
314
320
  try {
315
321
  const wallTimestamp = Date.now();
316
322
  const candidate = projectRecord(ctx, () => Math.floor(wallTimestamp / 1000));