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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +111 -23
  2. package/README.md +10 -1
  3. package/README.zh-CN.md +8 -1
  4. package/docs/commands.md +28 -4
  5. package/docs/commands.zh-CN.md +20 -4
  6. package/docs/dsh.md +2 -2
  7. package/docs/dsh.zh-CN.md +3 -3
  8. package/docs/native.md +84 -10
  9. package/docs/native.zh-CN.md +66 -5
  10. package/docs/web-handoff.zh-CN.md +11 -3
  11. package/lib/cli/app.mjs +42 -1
  12. package/lib/cli/commands/coordination.mjs +7 -2
  13. package/lib/cli/commands/dsh.mjs +1 -1
  14. package/lib/cli/commands/gc.mjs +9 -4
  15. package/lib/cli/commands/hook.mjs +6 -1
  16. package/lib/cli/commands/install.mjs +48 -6
  17. package/lib/cli/commands/lifecycle.mjs +14 -3
  18. package/lib/cli/commands/native.mjs +16 -9
  19. package/lib/cli/commands/query.mjs +15 -10
  20. package/lib/cli/commands/run.mjs +7 -6
  21. package/lib/core/coordination/results.mjs +27 -10
  22. package/lib/db/connection.mjs +135 -10
  23. package/lib/db/schema.mjs +59 -0
  24. package/lib/discover.mjs +4 -4
  25. package/lib/guidance.mjs +3 -2
  26. package/lib/integrations/codex-interactions.mjs +13 -11
  27. package/lib/integrations/dsh-collaboration.mjs +6 -12
  28. package/lib/integrations/dsh-cordis.mjs +17 -13
  29. package/lib/integrations/dsh.mjs +12 -9
  30. package/lib/integrations/hooks.mjs +132 -40
  31. package/lib/integrations/mcp-elicitation.mjs +116 -0
  32. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  33. package/lib/integrations/native/interactions.mjs +2 -1
  34. package/lib/integrations/peers/identity.mjs +42 -7
  35. package/lib/process/identity.mjs +15 -14
  36. package/lib/runtime/buffer-evidence.mjs +2 -1
  37. package/lib/runtime/native/service.mjs +11 -5
  38. package/lib/runtime/native/store.mjs +70 -18
  39. package/lib/runtime/paths.mjs +17 -2
  40. package/lib/runtime/private-file.mjs +76 -0
  41. package/lib/runtime/private-state.mjs +546 -0
  42. package/lib/runtime/project-context.mjs +2 -1
  43. package/lib/runtime/project-path.mjs +148 -12
  44. package/lib/runtime/project-trust.mjs +79 -0
  45. package/lib/runtime/projects.mjs +6 -3
  46. package/lib/runtime/state-migration.mjs +697 -0
  47. package/lib/runtime/state.mjs +22 -11
  48. package/lib/shared/automatic-result-key.mjs +6 -0
  49. package/lib/shared/json-file.mjs +55 -6
  50. package/lib/ui/help.mjs +6 -4
  51. package/lib/web/codex-app-server.mjs +26 -5
  52. package/lib/web/codex-sessions.mjs +5 -3
  53. package/lib/web/cookie-auth.mjs +1 -1
  54. package/lib/web/external-sessions.mjs +54 -5
  55. package/lib/web/http-routes.mjs +13 -9
  56. package/lib/web/http.mjs +20 -2
  57. package/lib/web/native-sessions.mjs +14 -7
  58. package/lib/web/peer-actions.mjs +27 -27
  59. package/lib/web/project-contexts.mjs +2 -3
  60. package/lib/web/runtime-main.mjs +22 -5
  61. package/lib/web/runtime.mjs +18 -4
  62. package/lib/web/session-serialize.mjs +1 -1
  63. package/lib/web/startup.mjs +115 -22
  64. package/lib/web/task-results.mjs +20 -17
  65. package/lib/web/terminal-assets.mjs +18 -0
  66. package/lib/web/tls.mjs +84 -33
  67. package/lib/web/tmux-sessions.mjs +2 -2
  68. package/lib/web/tmux-stream.mjs +3 -4
  69. package/lib/web/ui-codex.mjs +161 -32
  70. package/lib/web/ui-interactions.mjs +137 -4
  71. package/lib/web/ui-native.mjs +31 -13
  72. package/lib/web/ui-template.mjs +10 -3
  73. package/package.json +1 -1
  74. package/scripts/mcp-form-live-fixture.mjs +81 -0
  75. package/scripts/native-installed-acceptance.mjs +413 -0
  76. package/scripts/regression.mjs +46 -29
  77. package/scripts/web-mcp-form-acceptance.mjs +119 -0
  78. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  79. package/scripts/web-native-interaction-acceptance.mjs +77 -10
@@ -0,0 +1,546 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { CliError } from '../shared/errors.mjs';
7
+ import { withFileLock } from '../shared/file-lock.mjs';
8
+ import { unsafeDirectoryAcl } from './project-trust.mjs';
9
+
10
+ const DIRECTORY_FLAGS = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW;
11
+ const FILE_READ_FLAGS = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
12
+ const FILE_CREATE_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT |
13
+ fs.constants.O_EXCL | fs.constants.O_NOFOLLOW;
14
+ const MANIFEST_NAME = '.project-root.json';
15
+ const MANIFEST_VERSION = 1;
16
+ const AUTHORITY_KINDS = new Set(['pending', 'fresh', 'migrated', 'purging', 'reset']);
17
+ const AUTHORITY_TRANSITIONS = {
18
+ missing: new Set(['pending', 'migrated', 'purging']),
19
+ pending: new Set(['fresh', 'migrated', 'purging']),
20
+ fresh: new Set(['migrated', 'purging']),
21
+ migrated: new Set(['purging']),
22
+ purging: new Set(['reset']),
23
+ reset: new Set(['fresh', 'migrated', 'purging'])
24
+ };
25
+
26
+ function forbidden(message) {
27
+ return new CliError('PROJECT_PATH_FORBIDDEN', message);
28
+ }
29
+
30
+ function inspectError(error, description, target) {
31
+ if (error instanceof CliError) return error;
32
+ return forbidden(`Cannot inspect ${description}: ${target}`);
33
+ }
34
+
35
+ function ownerUid() {
36
+ if (typeof process.getuid !== 'function' ||
37
+ fs.constants.O_DIRECTORY === undefined || fs.constants.O_NOFOLLOW === undefined) {
38
+ throw forbidden('Private project state requires POSIX ownership and no-follow file operations');
39
+ }
40
+ return BigInt(process.getuid());
41
+ }
42
+
43
+ function requestedRoot(root) {
44
+ if (typeof root !== 'string' || root.length === 0) {
45
+ throw forbidden('Project root must be a non-empty path');
46
+ }
47
+ return path.resolve(root);
48
+ }
49
+
50
+ function canonicalProjectRoot(root, { allowMissing = false } = {}) {
51
+ const requested = requestedRoot(root);
52
+ let canonical;
53
+ try {
54
+ canonical = fs.realpathSync(requested);
55
+ } catch (error) {
56
+ if (allowMissing && error?.code === 'ENOENT') {
57
+ // A dangling symlink is an existing, invalid root rather than a missing one.
58
+ try {
59
+ if (fs.lstatSync(requested).isSymbolicLink()) {
60
+ throw forbidden(`Project root is a dangling symlink: ${requested}`);
61
+ }
62
+ } catch (inspectionError) {
63
+ if (inspectionError instanceof CliError) throw inspectionError;
64
+ if (inspectionError?.code !== 'ENOENT') {
65
+ throw inspectError(inspectionError, 'project root', requested);
66
+ }
67
+ }
68
+ return { requested, canonical: requested, stat: null };
69
+ }
70
+ if (error?.code === 'ENOENT') {
71
+ throw new CliError('PROJECT_NOT_REGISTERED', `Project root does not exist: ${requested}`);
72
+ }
73
+ throw inspectError(error, 'project root', requested);
74
+ }
75
+
76
+ let stat;
77
+ try { stat = fs.statSync(canonical, { bigint: true }); }
78
+ catch (error) { throw inspectError(error, 'project root', canonical); }
79
+ if (!stat.isDirectory()) throw forbidden(`Project root is not a directory: ${requested}`);
80
+ return { requested, canonical, stat };
81
+ }
82
+
83
+ function canonicalHome({ allowMissing = false } = {}) {
84
+ const requested = path.resolve(os.homedir());
85
+ try {
86
+ const canonical = fs.realpathSync(requested);
87
+ if (!fs.statSync(canonical).isDirectory()) throw forbidden(`Home is not a directory: ${requested}`);
88
+ return canonical;
89
+ } catch (error) {
90
+ if (allowMissing && error?.code === 'ENOENT') {
91
+ try {
92
+ fs.lstatSync(requested);
93
+ } catch (inspectionError) {
94
+ if (inspectionError?.code === 'ENOENT') return requested;
95
+ throw inspectError(inspectionError, 'home directory', requested);
96
+ }
97
+ throw forbidden(`Home is an unresolved path: ${requested}`);
98
+ }
99
+ throw inspectError(error, 'home directory', requested);
100
+ }
101
+ }
102
+
103
+ function statePath(canonicalRoot, home) {
104
+ const hash = createHash('sha256').update(canonicalRoot).digest('hex');
105
+ return path.join(home, '.hello-cc', 'projects', hash);
106
+ }
107
+
108
+ function sameIdentity(left, right) {
109
+ return left.dev === right.dev && left.ino === right.ino;
110
+ }
111
+
112
+ function modeOf(stat) {
113
+ return Number(stat.mode & 0o7777n);
114
+ }
115
+
116
+ function inspectDirectory(target, description, {
117
+ mode = null, tighten = false, allowReadOnlyPublic = false
118
+ } = {}) {
119
+ let before;
120
+ try { before = fs.lstatSync(target, { bigint: true }); }
121
+ catch (error) { throw inspectError(error, description, target); }
122
+ if (before.isSymbolicLink() || !before.isDirectory() || before.uid !== ownerUid()) {
123
+ throw forbidden(`${description} must be a current-user-owned real directory: ${target}`);
124
+ }
125
+ let fd;
126
+ try {
127
+ fd = fs.openSync(target, DIRECTORY_FLAGS);
128
+ let opened = fs.fstatSync(fd, { bigint: true });
129
+ if (!opened.isDirectory() || !sameIdentity(before, opened) || opened.uid !== ownerUid() ||
130
+ unsafeDirectoryAcl(target, opened)) {
131
+ throw forbidden(`${description} changed during validation: ${target}`);
132
+ }
133
+ if (mode !== null && modeOf(opened) !== mode &&
134
+ !(allowReadOnlyPublic && modeOf(opened) === 0o755)) {
135
+ if (!tighten || (modeOf(opened) & 0o022) !== 0) {
136
+ throw forbidden(`${description} must have mode ${mode.toString(8)}: ${target}`);
137
+ }
138
+ fs.fchmodSync(fd, mode);
139
+ opened = fs.fstatSync(fd, { bigint: true });
140
+ if (modeOf(opened) !== mode) {
141
+ throw forbidden(`Cannot secure ${description}: ${target}`);
142
+ }
143
+ }
144
+ const after = fs.lstatSync(target, { bigint: true });
145
+ if (after.isSymbolicLink() || !sameIdentity(opened, after)) {
146
+ throw forbidden(`${description} changed during validation: ${target}`);
147
+ }
148
+ return opened;
149
+ } catch (error) {
150
+ throw inspectError(error, description, target);
151
+ } finally {
152
+ if (fd !== undefined) fs.closeSync(fd);
153
+ }
154
+ }
155
+
156
+ function inspectHomeAncestry(home) {
157
+ const components = [];
158
+ for (let current = home;; current = path.dirname(current)) {
159
+ components.push(current);
160
+ if (path.dirname(current) === current) break;
161
+ }
162
+ components.reverse();
163
+ const uid = ownerUid();
164
+ for (const component of components) {
165
+ let stat;
166
+ try { stat = fs.lstatSync(component, { bigint: true }); }
167
+ catch (error) { throw inspectError(error, 'home ancestor', component); }
168
+ const permissions = modeOf(stat);
169
+ if (stat.isSymbolicLink() || !stat.isDirectory() || (stat.uid !== uid && stat.uid !== 0n) ||
170
+ unsafeDirectoryAcl(component, stat)) {
171
+ throw forbidden(`Home ancestor must be a trusted real directory: ${component}`);
172
+ }
173
+ // Sticky directories such as /tmp prevent another UID from renaming an
174
+ // owned child. Their child is checked in the next iteration.
175
+ if ((permissions & 0o022) !== 0 && (permissions & 0o1000) === 0) {
176
+ throw forbidden(`Home ancestor is writable by another user: ${component}`);
177
+ }
178
+ let fd;
179
+ try {
180
+ fd = fs.openSync(component, DIRECTORY_FLAGS);
181
+ const opened = fs.fstatSync(fd, { bigint: true });
182
+ if (!opened.isDirectory() || !sameIdentity(stat, opened) || opened.uid !== stat.uid) {
183
+ throw forbidden(`Home ancestor changed during validation: ${component}`);
184
+ }
185
+ } catch (error) {
186
+ throw inspectError(error, 'home ancestor', component);
187
+ } finally {
188
+ if (fd !== undefined) fs.closeSync(fd);
189
+ }
190
+ }
191
+ }
192
+
193
+ function optionalDirectory(target, description, {
194
+ create, mode, tighten = false, allowReadOnlyPublic = false
195
+ }) {
196
+ let exists;
197
+ try { fs.lstatSync(target); exists = true; }
198
+ catch (error) {
199
+ if (error?.code !== 'ENOENT') throw inspectError(error, description, target);
200
+ exists = false;
201
+ }
202
+ if (!exists && !create) return false;
203
+ if (!exists) {
204
+ try { fs.mkdirSync(target, { mode }); }
205
+ catch (error) {
206
+ if (error?.code !== 'EEXIST') throw forbidden(`Cannot create ${description}: ${target}`);
207
+ }
208
+ }
209
+ inspectDirectory(target, description, {
210
+ mode, tighten: create && tighten, allowReadOnlyPublic: !create && allowReadOnlyPublic
211
+ });
212
+ return true;
213
+ }
214
+
215
+ function expectedManifest(root) {
216
+ return {
217
+ version: MANIFEST_VERSION,
218
+ canonicalRoot: root.canonical,
219
+ dev: root.stat.dev.toString(),
220
+ ino: root.stat.ino.toString()
221
+ };
222
+ }
223
+
224
+ function readManifest(manifestPath) {
225
+ let before;
226
+ try { before = fs.lstatSync(manifestPath, { bigint: true }); }
227
+ catch (error) { throw inspectError(error, 'project state manifest', manifestPath); }
228
+ if (before.isSymbolicLink() || !before.isFile() || before.uid !== ownerUid() ||
229
+ before.nlink !== 1n || modeOf(before) !== 0o600 || before.size > 16384n ||
230
+ unsafeDirectoryAcl(manifestPath, before)) {
231
+ throw forbidden(`Project state manifest must be a private regular file: ${manifestPath}`);
232
+ }
233
+ let fd;
234
+ try {
235
+ fd = fs.openSync(manifestPath, FILE_READ_FLAGS);
236
+ const opened = fs.fstatSync(fd, { bigint: true });
237
+ if (!opened.isFile() || !sameIdentity(before, opened) || opened.nlink !== 1n ||
238
+ opened.uid !== ownerUid() || modeOf(opened) !== 0o600 || opened.size > 16384n ||
239
+ unsafeDirectoryAcl(manifestPath, opened)) {
240
+ throw forbidden(`Project state manifest changed during validation: ${manifestPath}`);
241
+ }
242
+ const data = fs.readFileSync(fd, { encoding: 'utf8' });
243
+ const after = fs.lstatSync(manifestPath, { bigint: true });
244
+ if (after.isSymbolicLink() || !sameIdentity(opened, after)) {
245
+ throw forbidden(`Project state manifest changed during validation: ${manifestPath}`);
246
+ }
247
+ try { return JSON.parse(data); }
248
+ catch { throw forbidden(`Invalid project state manifest: ${manifestPath}`); }
249
+ } catch (error) {
250
+ throw inspectError(error, 'project state manifest', manifestPath);
251
+ } finally {
252
+ if (fd !== undefined) fs.closeSync(fd);
253
+ }
254
+ }
255
+
256
+ function ensureManifest(directory, root, create) {
257
+ const manifestPath = path.join(directory, MANIFEST_NAME);
258
+ let exists;
259
+ try { fs.lstatSync(manifestPath); exists = true; }
260
+ catch (error) {
261
+ if (error?.code !== 'ENOENT') throw inspectError(error, 'project state manifest', manifestPath);
262
+ exists = false;
263
+ }
264
+ if (!exists) {
265
+ if (!create || fs.readdirSync(directory).length !== 0) {
266
+ throw forbidden(`Project state directory has no binding manifest: ${directory}`);
267
+ }
268
+ const temporary = path.join(path.dirname(directory),
269
+ `.${path.basename(directory)}.manifest-tmp-${randomBytes(16).toString('hex')}`);
270
+ let fd;
271
+ try {
272
+ fd = fs.openSync(temporary, FILE_CREATE_FLAGS, 0o600);
273
+ fs.writeFileSync(fd, `${JSON.stringify(expectedManifest(root))}\n`);
274
+ fs.fchmodSync(fd, 0o600);
275
+ fs.fsyncSync(fd);
276
+ fs.closeSync(fd);
277
+ fd = undefined;
278
+ fs.renameSync(temporary, manifestPath);
279
+ fsyncDirectory(directory);
280
+ } catch (error) {
281
+ if (error?.code !== 'EEXIST') throw inspectError(error, 'project state manifest', manifestPath);
282
+ } finally {
283
+ if (fd !== undefined) fs.closeSync(fd);
284
+ try { fs.unlinkSync(temporary); } catch { /* A private temp is inert. */ }
285
+ }
286
+ }
287
+ const actual = readManifest(manifestPath);
288
+ const expected = expectedManifest(root);
289
+ if (actual?.version !== expected.version || actual.canonicalRoot !== expected.canonicalRoot ||
290
+ actual.dev !== expected.dev || actual.ino !== expected.ino) {
291
+ throw forbidden(`Project root identity differs from private state binding: ${root.canonical}. ` +
292
+ 'Back up the old private store and authority marker, stop all writers, and review both root identities ' +
293
+ 'before manual recovery; automatic rebinding is refused.');
294
+ }
295
+ }
296
+
297
+ function assertProjectUnchanged(root) {
298
+ let current;
299
+ try { current = canonicalProjectRoot(root.requested); }
300
+ catch { throw forbidden(`Project root changed during private state validation: ${root.requested}`); }
301
+ if (current.canonical !== root.canonical || !sameIdentity(current.stat, root.stat)) {
302
+ throw forbidden(`Project root changed during private state validation: ${root.requested}`);
303
+ }
304
+ }
305
+
306
+ // Path derivation is read-only. A missing project root gets a lexical absolute
307
+ // path solely for hashing; state access always requires an existing directory.
308
+ export function privateProjectStateDir(root) {
309
+ const project = canonicalProjectRoot(root, { allowMissing: true });
310
+ return statePath(project.canonical, canonicalHome({ allowMissing: true }));
311
+ }
312
+
313
+ export function privateProjectAuthorityPath(root) {
314
+ return `${privateProjectStateDir(root)}.authority.json`;
315
+ }
316
+
317
+ export function inspectPrivateProjectAuthority(root) {
318
+ const project = canonicalProjectRoot(root);
319
+ const marker = privateProjectAuthorityPath(project.canonical);
320
+ try { fs.lstatSync(marker); }
321
+ catch (error) {
322
+ if (error?.code === 'ENOENT') return null;
323
+ throw inspectError(error, 'project authority marker', marker);
324
+ }
325
+ const actual = readManifest(marker);
326
+ const expected = expectedManifest(project);
327
+ if (actual?.version !== expected.version || actual.canonicalRoot !== expected.canonicalRoot ||
328
+ actual.dev !== expected.dev || actual.ino !== expected.ino) {
329
+ throw forbidden(`Project authority marker differs from project root: ${marker}. ` +
330
+ 'Back up the old private store and authority marker, stop all writers, and review both root identities ' +
331
+ 'before manual recovery; automatic purge or rebinding is refused.');
332
+ }
333
+ // Markers from earlier releases were written only by offline migration.
334
+ const kind = actual.kind ?? 'migrated';
335
+ if (!AUTHORITY_KINDS.has(kind)) {
336
+ throw forbidden(`Invalid project authority marker kind: ${marker}`);
337
+ }
338
+ return kind;
339
+ }
340
+
341
+ function fsyncDirectory(directory) {
342
+ const fd = fs.openSync(directory, DIRECTORY_FLAGS);
343
+ try { fs.fsyncSync(fd); }
344
+ finally { fs.closeSync(fd); }
345
+ }
346
+
347
+ function writePrivateProjectAuthorityKind(project, home, projects, kind) {
348
+ if (!AUTHORITY_KINDS.has(kind)) throw forbidden(`Invalid project authority kind: ${kind}`);
349
+ const marker = `${statePath(project.canonical, home)}.authority.json`;
350
+ const current = inspectPrivateProjectAuthority(project.canonical);
351
+ if (current === kind) return marker;
352
+ if (!AUTHORITY_TRANSITIONS[current ?? 'missing']?.has(kind)) {
353
+ throw forbidden(`Invalid project authority transition ${current ?? 'missing'} -> ${kind}: ${marker}`);
354
+ }
355
+ const contents = `${JSON.stringify({ ...expectedManifest(project), kind })}\n`;
356
+ const temporary = `${marker}.tmp-${randomBytes(16).toString('hex')}`;
357
+ let fd;
358
+ try {
359
+ fd = fs.openSync(temporary, FILE_CREATE_FLAGS, 0o600);
360
+ fs.writeFileSync(fd, contents);
361
+ fs.fchmodSync(fd, 0o600);
362
+ fs.fsyncSync(fd);
363
+ fs.closeSync(fd);
364
+ fd = undefined;
365
+ if (inspectPrivateProjectAuthority(project.canonical) !== current) {
366
+ throw forbidden(`Project authority marker changed during update: ${marker}`);
367
+ }
368
+ // Even first publication is atomic: an interrupted writer only leaves an
369
+ // inert temporary, never a truncated authority marker at the final name.
370
+ fs.renameSync(temporary, marker);
371
+ } catch (error) {
372
+ throw inspectError(error, 'project authority marker', marker);
373
+ } finally {
374
+ if (fd !== undefined) fs.closeSync(fd);
375
+ try { fs.unlinkSync(temporary); } catch { /* A private temp is inert. */ }
376
+ }
377
+ fsyncDirectory(projects);
378
+ assertProjectUnchanged(project);
379
+ return marker;
380
+ }
381
+
382
+ // Every operation that can establish, replace, or delete a private binding
383
+ // uses this one lock domain. The callback is synchronous and must not acquire
384
+ // the same transition lock again.
385
+ export function withPrivateProjectStateTransition(root, fn) {
386
+ if (typeof fn !== 'function') throw new TypeError('private state transition requires a callback');
387
+ const project = canonicalProjectRoot(root);
388
+ const home = canonicalHome();
389
+ inspectHomeAncestry(home);
390
+ const global = path.join(home, '.hello-cc');
391
+ const projects = path.join(global, 'projects');
392
+ optionalDirectory(global, 'global state directory', {
393
+ create: true, mode: 0o700, tighten: true
394
+ });
395
+ optionalDirectory(projects, 'private projects directory', {
396
+ create: true, mode: 0o700, tighten: true
397
+ });
398
+ const directory = statePath(project.canonical, home);
399
+ return withFileLock(`${directory}.transition`, () => {
400
+ inspectHomeAncestry(home);
401
+ inspectDirectory(global, 'global state directory', { mode: 0o700 });
402
+ inspectDirectory(projects, 'private projects directory', { mode: 0o700 });
403
+ assertProjectUnchanged(project);
404
+ const result = fn({
405
+ directory, projects, project, home,
406
+ authorityKind: () => inspectPrivateProjectAuthority(project.canonical),
407
+ setAuthorityKind: (kind) => writePrivateProjectAuthorityKind(project, home, projects, kind)
408
+ });
409
+ assertProjectUnchanged(project);
410
+ return result;
411
+ }, { createParent: false, timeoutMs: 300000 });
412
+ }
413
+
414
+ export function privateStateBaseDir() {
415
+ return path.join(canonicalHome({ allowMissing: true }), '.hello-cc');
416
+ }
417
+
418
+ // Global runtime pointers and the project registry share this parent. Check
419
+ // it even when no project has been routed to the private project store.
420
+ export function validatedGlobalStateDir() {
421
+ const home = canonicalHome();
422
+ inspectHomeAncestry(home);
423
+ const global = path.join(home, '.hello-cc');
424
+ optionalDirectory(global, 'global state directory', {
425
+ create: false, mode: 0o700, allowReadOnlyPublic: true
426
+ });
427
+ return global;
428
+ }
429
+
430
+ export function ensurePrivateGlobalSubdirectory(name, { create = true } = {}) {
431
+ if (!name || name !== path.basename(name) || name === '.' || name === '..') {
432
+ throw forbidden(`Invalid global state subdirectory: ${name}`);
433
+ }
434
+ const home = canonicalHome();
435
+ inspectHomeAncestry(home);
436
+ const global = path.join(home, '.hello-cc');
437
+ if (!optionalDirectory(global, 'global state directory', {
438
+ create, mode: 0o700, tighten: create, allowReadOnlyPublic: !create
439
+ })) return null;
440
+ const directory = path.join(global, name);
441
+ if (!optionalDirectory(directory, 'global state subdirectory', {
442
+ create, mode: 0o700, tighten: create
443
+ })) return null;
444
+ return directory;
445
+ }
446
+
447
+ function inspectPrivateProjectStateDir(project, home, create, setAuthorityKind = null) {
448
+ inspectHomeAncestry(home);
449
+ const global = path.join(home, '.hello-cc');
450
+ const projects = path.join(global, 'projects');
451
+ const directory = statePath(project.canonical, home);
452
+
453
+ // A pre-existing 0755 global directory can be read without mutation. Only
454
+ // create mode may tighten it, and writable-by-others directories are rejected.
455
+ if (!optionalDirectory(global, 'global state directory', {
456
+ create: Boolean(create), mode: 0o700, tighten: true,
457
+ allowReadOnlyPublic: true
458
+ })) return null;
459
+ if (!optionalDirectory(projects, 'private projects directory', {
460
+ create: Boolean(create), mode: 0o700, tighten: true
461
+ })) return null;
462
+ const authority = inspectPrivateProjectAuthority(project.canonical);
463
+ let directoryExists = false;
464
+ try { fs.lstatSync(directory); directoryExists = true; }
465
+ catch (error) {
466
+ if (error?.code !== 'ENOENT') throw inspectError(error, 'private project state directory', directory);
467
+ }
468
+ if (authority === 'purging' || (authority === 'reset' && directoryExists)) {
469
+ throw new CliError('STATE_PURGE_INCOMPLETE',
470
+ `Private project state purge is incomplete: ${directory}. Retry uninstall --purge --yes before use.`);
471
+ }
472
+ if ((authority === 'fresh' || authority === 'migrated') && !directoryExists) {
473
+ throw new CliError('STATE_AUTHORITY_MISSING',
474
+ `Previously established private project state is missing: ${directory}. Restore it from backup; ` +
475
+ 'if the legacy source still exists, stop all old writers and run ' +
476
+ 'hcc --root DIR migrate-state --offline --yes; otherwise run ' +
477
+ 'hcc --root DIR uninstall --purge --yes to explicitly reset the missing state.');
478
+ }
479
+ let manifestExists = false;
480
+ if (directoryExists) {
481
+ try { fs.lstatSync(path.join(directory, MANIFEST_NAME)); manifestExists = true; }
482
+ catch (error) {
483
+ if (error?.code !== 'ENOENT') throw inspectError(error, 'project state manifest', directory);
484
+ }
485
+ }
486
+ if (create && !manifestExists) {
487
+ // Resolver callers also check the legacy source. Repeat at the actual
488
+ // transition boundary: a concurrent purge can invalidate their earlier
489
+ // choice to finish an interrupted initialization.
490
+ const legacy = path.join(project.canonical, '.hello-cc');
491
+ try {
492
+ const entry = fs.lstatSync(legacy);
493
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
494
+ throw forbidden(`Legacy project state is not a real directory: ${legacy}`);
495
+ }
496
+ throw new CliError('STATE_MIGRATION_REQUIRED',
497
+ `Legacy project state requires an offline migration before use: ${legacy}`);
498
+ } catch (error) {
499
+ if (error?.code !== 'ENOENT') throw error;
500
+ }
501
+ }
502
+ if (create && authority === null) {
503
+ setAuthorityKind('pending');
504
+ }
505
+ if (!optionalDirectory(directory, 'private project state directory', {
506
+ create: Boolean(create), mode: 0o700, tighten: true
507
+ })) return null;
508
+
509
+ ensureManifest(directory, project, Boolean(create));
510
+ if (create && (authority === null || authority === 'pending' || authority === 'reset')) {
511
+ fsyncDirectory(projects);
512
+ setAuthorityKind('fresh');
513
+ }
514
+ assertProjectUnchanged(project);
515
+ return directory;
516
+ }
517
+
518
+ export function ensurePrivateProjectStateDir(root, { create = false } = {}) {
519
+ if (create) {
520
+ return withPrivateProjectStateTransition(root, ({ project, home, setAuthorityKind }) =>
521
+ inspectPrivateProjectStateDir(project, home, true, setAuthorityKind));
522
+ }
523
+ return inspectPrivateProjectStateDir(canonicalProjectRoot(root), canonicalHome(), false);
524
+ }
525
+
526
+ // The purging marker is a durable intent record. If deletion is interrupted,
527
+ // normal state access stays closed and an explicit purge retry completes it.
528
+ export function purgePrivateProjectState(root) {
529
+ return withPrivateProjectStateTransition(root, ({ directory, projects, authorityKind, setAuthorityKind }) => {
530
+ // The directory may be partially deleted after an earlier crash, so its
531
+ // manifest need not remain. Its private ownership and no-follow identity
532
+ // are still required before recursive deletion.
533
+ let exists = false;
534
+ try { fs.lstatSync(directory); exists = true; }
535
+ catch (error) {
536
+ if (error?.code !== 'ENOENT') throw inspectError(error, 'private project state directory', directory);
537
+ }
538
+ if (exists) inspectDirectory(directory, 'private project state directory', { mode: 0o700 });
539
+ authorityKind();
540
+ setAuthorityKind('purging');
541
+ fs.rmSync(directory, { recursive: true, force: true });
542
+ fsyncDirectory(projects);
543
+ setAuthorityKind('reset');
544
+ return directory;
545
+ });
546
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { projectDbPath } from './paths.mjs';
5
6
 
6
7
  export function runGit(args, cwd) {
7
8
  const result = spawnSync('git', args, {
@@ -17,7 +18,7 @@ export function hasHccRootSync(cwd) {
17
18
  if (!cwd) return null;
18
19
  const dir = path.resolve(cwd);
19
20
  const hccDir = path.join(dir, '.hello-cc');
20
- return fs.existsSync(path.join(hccDir, 'mesh.db')) ||
21
+ return fs.existsSync(projectDbPath(dir)) ||
21
22
  fs.existsSync(path.join(hccDir, 'config.json'));
22
23
  }
23
24