@logicseek/hello-cc 0.1.9 → 1.0.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 (99) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -3
  3. package/README.zh-CN.md +48 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/guide.md +97 -3
  12. package/docs/guide.zh-CN.md +89 -2
  13. package/lib/cli/app.mjs +644 -0
  14. package/lib/cli/commands/coordination.mjs +249 -0
  15. package/lib/cli/commands/doctor.mjs +81 -0
  16. package/lib/cli/commands/gc.mjs +1002 -0
  17. package/lib/cli/commands/hook.mjs +205 -0
  18. package/lib/cli/commands/install.mjs +294 -0
  19. package/lib/cli/commands/lifecycle.mjs +101 -0
  20. package/lib/cli/commands/lock.mjs +197 -0
  21. package/lib/cli/commands/misc.mjs +76 -0
  22. package/lib/cli/commands/msg.mjs +152 -0
  23. package/lib/cli/commands/peer.mjs +249 -0
  24. package/lib/cli/commands/query.mjs +96 -0
  25. package/lib/cli/commands/run.mjs +463 -0
  26. package/lib/cli/commands/task.mjs +405 -0
  27. package/lib/cli/commands/team.mjs +131 -0
  28. package/lib/cli/commands/tmux.mjs +838 -0
  29. package/lib/cli/commands/up.mjs +135 -0
  30. package/lib/cli-args.mjs +16 -2
  31. package/lib/coordination-state.mjs +112 -51
  32. package/lib/core/coordination/automation.mjs +5 -8
  33. package/lib/core/coordination/clock-safety.mjs +323 -0
  34. package/lib/core/coordination/event-retention.mjs +17 -0
  35. package/lib/core/coordination/gc-plan.mjs +614 -0
  36. package/lib/core/coordination/lease-renewal.mjs +102 -0
  37. package/lib/core/coordination/lock-evidence.mjs +77 -0
  38. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  39. package/lib/core/coordination/tasks.mjs +156 -36
  40. package/lib/core/peers/evidence-runtime.mjs +312 -0
  41. package/lib/core/peers/evidence.mjs +103 -0
  42. package/lib/core/peers/hook-owner.mjs +32 -0
  43. package/lib/core/peers/liveness.mjs +44 -4
  44. package/lib/core/peers/peer-helpers.mjs +121 -0
  45. package/lib/core/peers/session.mjs +8 -2
  46. package/lib/core/peers/tmux-safety.mjs +290 -0
  47. package/lib/core/sessions/launch.mjs +13 -1
  48. package/lib/db/connection.mjs +104 -0
  49. package/lib/db/events.mjs +34 -0
  50. package/lib/db/migration-backup.mjs +225 -0
  51. package/lib/db/schema.mjs +102 -5
  52. package/lib/db/stores/peers.mjs +34 -0
  53. package/lib/integrations/peers/identity.mjs +62 -16
  54. package/lib/integrations/shims/script.mjs +111 -9
  55. package/lib/process/identity.mjs +222 -0
  56. package/lib/process/pty-lifecycle.mjs +149 -0
  57. package/lib/runtime/buffer-directory-lease.mjs +48 -0
  58. package/lib/runtime/buffer-evidence.mjs +336 -0
  59. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  60. package/lib/runtime/buffer-gc.mjs +270 -0
  61. package/lib/runtime/client.mjs +63 -7
  62. package/lib/runtime/fatal-shutdown.mjs +54 -0
  63. package/lib/runtime/project-path.mjs +139 -0
  64. package/lib/runtime/projects.mjs +320 -40
  65. package/lib/runtime/state.mjs +227 -27
  66. package/lib/shared/cleanup-error.mjs +54 -0
  67. package/lib/shared/clock-grace.mjs +106 -0
  68. package/lib/shared/errors.mjs +9 -0
  69. package/lib/shared/file-lock.mjs +377 -0
  70. package/lib/shared/json-file.mjs +6 -2
  71. package/lib/shared/redact.mjs +60 -0
  72. package/lib/shared/socket-lock-worker.mjs +250 -0
  73. package/lib/terminal/tmux.mjs +131 -27
  74. package/lib/tmux.mjs +4 -0
  75. package/lib/ui/help.mjs +53 -15
  76. package/lib/web/api-version.mjs +42 -0
  77. package/lib/web/auto-attach.mjs +185 -0
  78. package/lib/web/buffer-gc-runtime.mjs +175 -0
  79. package/lib/web/cookie-auth.mjs +119 -0
  80. package/lib/web/external-sessions.mjs +254 -0
  81. package/lib/web/http-routes.mjs +542 -0
  82. package/lib/web/http.mjs +135 -6
  83. package/lib/web/liveness-reaper.mjs +145 -0
  84. package/lib/web/peer-actions.mjs +132 -23
  85. package/lib/web/project-contexts.mjs +183 -0
  86. package/lib/web/pty-sessions.mjs +203 -0
  87. package/lib/web/runtime-main.mjs +717 -0
  88. package/lib/web/runtime.mjs +91 -36
  89. package/lib/web/session-serialize.mjs +0 -0
  90. package/lib/web/startup.mjs +335 -0
  91. package/lib/web/tls.mjs +365 -0
  92. package/lib/web/tmux-sessions.mjs +1361 -0
  93. package/lib/web/tmux-stream.mjs +166 -0
  94. package/lib/web/ui-template.mjs +152 -20
  95. package/package.json +6 -3
  96. package/scripts/check-free-identifiers.mjs +290 -0
  97. package/scripts/regression.mjs +5404 -273
  98. package/docs/architecture-split-review.md +0 -217
  99. package/docs/layout-split-stack-audit.md +0 -298
@@ -1,76 +1,356 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { projectDbPath, projectRegistryPath } from './paths.mjs';
4
+ import { withFileLock } from '../shared/file-lock.mjs';
5
+ import { writeJsonSafe } from '../shared/json-file.mjs';
6
+
7
+ const ROOT_EXISTS = Symbol('projectRootExists');
8
+ const ROOT_WAS_CANONICAL = Symbol('projectRootWasCanonical');
9
+ const RAW_ROOT = Symbol('projectRawRoot');
10
+ const TIMESTAMP_VALID = Symbol('projectTimestampValid');
4
11
 
5
12
  function registryTimestamp(nowFn = () => Math.floor(Date.now() / 1000)) {
6
13
  return nowFn();
7
14
  }
8
15
 
9
- function normalizeProjectEntry(project) {
16
+ function sameFilesystemObject(left, right) {
17
+ return left?.dev === right?.dev && left?.ino === right?.ino;
18
+ }
19
+
20
+ function missingPath(error) {
21
+ return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
22
+ }
23
+
24
+ function invalidProjectRoot(message, cause = null) {
25
+ const error = new Error(message, cause ? { cause } : undefined);
26
+ error.code = 'ERR_INVALID_PROJECT_ROOT';
27
+ return error;
28
+ }
29
+
30
+ function invalidProjectDb(message, cause = null) {
31
+ const error = new Error(message, cause ? { cause } : undefined);
32
+ error.code = 'ERR_INVALID_PROJECT_DB';
33
+ return error;
34
+ }
35
+
36
+ function pathWithin(parent, candidate) {
37
+ const relative = path.relative(parent, candidate);
38
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) &&
39
+ relative !== '..' && !path.isAbsolute(relative));
40
+ }
41
+
42
+ function normalizedTimestamp(value) {
43
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
44
+ return { value, valid: true };
45
+ }
46
+ if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value.trim())) {
47
+ const parsed = Number(value.trim());
48
+ if (Number.isSafeInteger(parsed)) return { value: parsed, valid: true };
49
+ }
50
+ return { value: 0, valid: false };
51
+ }
52
+
53
+ function canonicalProjectRoot(value, { requireExisting = false } = {}) {
54
+ const resolved = path.resolve(value);
55
+ let before;
56
+ try {
57
+ before = fs.statSync(resolved);
58
+ } catch (error) {
59
+ if (missingPath(error) && !requireExisting) {
60
+ return { root: resolved, exists: false };
61
+ }
62
+ if (requireExisting) {
63
+ throw invalidProjectRoot(`project root is not an existing directory: ${resolved}`, error);
64
+ }
65
+ return null;
66
+ }
67
+ if (!before.isDirectory()) {
68
+ if (requireExisting) throw invalidProjectRoot(`project root is not a directory: ${resolved}`);
69
+ return null;
70
+ }
71
+
72
+ try {
73
+ const canonical = fs.realpathSync.native(resolved);
74
+ const canonicalStat = fs.statSync(canonical);
75
+ const after = fs.statSync(resolved);
76
+ const confirmed = fs.realpathSync.native(resolved);
77
+ if (!canonicalStat.isDirectory() || !after.isDirectory() ||
78
+ canonical !== confirmed ||
79
+ !sameFilesystemObject(before, canonicalStat) ||
80
+ !sameFilesystemObject(before, after)) {
81
+ throw invalidProjectRoot(`project root changed during canonicalization: ${resolved}`);
82
+ }
83
+ return { root: canonical, exists: true };
84
+ } catch (error) {
85
+ if (requireExisting) {
86
+ if (error?.code === 'ERR_INVALID_PROJECT_ROOT') throw error;
87
+ throw invalidProjectRoot(`project root changed during canonicalization: ${resolved}`, error);
88
+ }
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function canonicalProjectDb(value, rawRoot, rootInfo, { strict = false } = {}) {
94
+ function reject(message, cause = null) {
95
+ if (strict) throw invalidProjectDb(message, cause);
96
+ return null;
97
+ }
98
+
99
+ const rawDb = path.resolve(value);
100
+ const relativeToRawRoot = path.relative(rawRoot, rawDb);
101
+ const db = pathWithin(rawRoot, rawDb)
102
+ ? path.resolve(rootInfo.root, relativeToRawRoot)
103
+ : rawDb;
104
+
105
+ if (!rootInfo.exists) {
106
+ return pathWithin(rootInfo.root, db)
107
+ ? db
108
+ : reject(`project DB is outside the project root: ${rawDb}`);
109
+ }
110
+
111
+ let ancestor = db;
112
+ let before;
113
+ while (true) {
114
+ try {
115
+ before = fs.statSync(ancestor);
116
+ break;
117
+ } catch (error) {
118
+ if (!missingPath(error)) {
119
+ return reject(`project DB path cannot be resolved safely: ${rawDb}`, error);
120
+ }
121
+ const parent = path.dirname(ancestor);
122
+ if (parent === ancestor) {
123
+ return reject(`project DB path has no existing parent: ${rawDb}`, error);
124
+ }
125
+ ancestor = parent;
126
+ }
127
+ }
128
+
129
+ const dbExists = ancestor === db;
130
+ if ((dbExists && before.isDirectory()) || (!dbExists && !before.isDirectory())) {
131
+ return reject(`project DB path is not a file under an existing directory: ${rawDb}`);
132
+ }
133
+
134
+ try {
135
+ const canonicalAncestor = fs.realpathSync.native(ancestor);
136
+ const canonicalStat = fs.statSync(canonicalAncestor);
137
+ const after = fs.statSync(ancestor);
138
+ const confirmed = fs.realpathSync.native(ancestor);
139
+ if (canonicalAncestor !== confirmed ||
140
+ !sameFilesystemObject(before, canonicalStat) ||
141
+ !sameFilesystemObject(before, after) ||
142
+ (dbExists ? canonicalStat.isDirectory() : !canonicalStat.isDirectory())) {
143
+ return reject(`project DB path changed during canonicalization: ${rawDb}`);
144
+ }
145
+ const canonicalDb = dbExists
146
+ ? canonicalAncestor
147
+ : path.resolve(canonicalAncestor, path.relative(ancestor, db));
148
+ if (!pathWithin(rootInfo.root, canonicalDb)) {
149
+ return reject(`project DB is outside the project root: ${rawDb}`);
150
+ }
151
+ return canonicalDb;
152
+ } catch (error) {
153
+ if (error?.code === 'ERR_INVALID_PROJECT_DB') throw error;
154
+ return reject(`project DB path changed during canonicalization: ${rawDb}`, error);
155
+ }
156
+ }
157
+
158
+ function normalizeProjectEntry(project, options = {}) {
10
159
  if (!project?.root) return null;
11
- const root = path.resolve(project.root);
12
- return {
13
- root,
14
- db: path.resolve(project.db || projectDbPath(root)),
15
- name: String(project.name || path.basename(root) || root),
16
- last_seen_at: Number.parseInt(project.last_seen_at || '0', 10) || 0
160
+ const rawRoot = project[RAW_ROOT] || path.resolve(project.root);
161
+ const rootInfo = canonicalProjectRoot(project.root, options);
162
+ if (!rootInfo) return null;
163
+ const db = canonicalProjectDb(
164
+ project.db || projectDbPath(rawRoot),
165
+ rawRoot,
166
+ rootInfo,
167
+ { strict: options.requireExisting === true }
168
+ );
169
+ if (!db) return null;
170
+ if (rootInfo.exists) {
171
+ const confirmedRoot = canonicalProjectRoot(project.root, options);
172
+ if (!confirmedRoot || !confirmedRoot.exists || confirmedRoot.root !== rootInfo.root) {
173
+ if (options.requireExisting) {
174
+ throw invalidProjectRoot(`project root changed during DB canonicalization: ${rawRoot}`);
175
+ }
176
+ return null;
177
+ }
178
+ }
179
+ const timestamp = project[TIMESTAMP_VALID] === undefined
180
+ ? normalizedTimestamp(project.last_seen_at)
181
+ : { value: project.last_seen_at, valid: project[TIMESTAMP_VALID] };
182
+ const row = {
183
+ root: rootInfo.root,
184
+ db,
185
+ name: String(project.name || path.basename(rootInfo.root) || rootInfo.root),
186
+ last_seen_at: timestamp.value
17
187
  };
188
+ Object.defineProperty(row, ROOT_EXISTS, { value: rootInfo.exists });
189
+ Object.defineProperty(row, ROOT_WAS_CANONICAL, {
190
+ value: project[ROOT_WAS_CANONICAL] ?? rawRoot === rootInfo.root
191
+ });
192
+ Object.defineProperty(row, RAW_ROOT, { value: rawRoot });
193
+ Object.defineProperty(row, TIMESTAMP_VALID, { value: timestamp.valid });
194
+ return row;
18
195
  }
19
196
 
20
- function projectRootExists(project) {
21
- if (!project?.root) return false;
22
- try {
23
- return fs.existsSync(project.root) && fs.statSync(project.root).isDirectory();
24
- } catch {
25
- return false;
197
+ function preferLexicallySmaller(left, right) {
198
+ if (left === right) return 0;
199
+ return left < right ? 1 : -1;
200
+ }
201
+
202
+ function compareProjectPreference(left, right) {
203
+ if (left[TIMESTAMP_VALID] !== right[TIMESTAMP_VALID]) {
204
+ return left[TIMESTAMP_VALID] ? 1 : -1;
205
+ }
206
+ if (left.last_seen_at !== right.last_seen_at) {
207
+ return left.last_seen_at > right.last_seen_at ? 1 : -1;
26
208
  }
209
+ if (left[ROOT_WAS_CANONICAL] !== right[ROOT_WAS_CANONICAL]) {
210
+ return left[ROOT_WAS_CANONICAL] ? 1 : -1;
211
+ }
212
+ return preferLexicallySmaller(left.db, right.db) ||
213
+ preferLexicallySmaller(left.name, right.name) ||
214
+ preferLexicallySmaller(left[RAW_ROOT], right[RAW_ROOT]);
27
215
  }
28
216
 
29
- export function projectRecord(ctx, nowFn) {
217
+ function normalizedProjectRows(projects, requiredRoots = new Set()) {
218
+ const unique = new Map();
219
+ for (const project of projects) {
220
+ const resolvedRoot = project?.root ? path.resolve(project.root) : null;
221
+ const row = normalizeProjectEntry(project, {
222
+ requireExisting: resolvedRoot !== null && requiredRoots.has(resolvedRoot)
223
+ });
224
+ if (!row) continue;
225
+ const current = unique.get(row.root);
226
+ if (!current || compareProjectPreference(row, current) > 0) unique.set(row.root, row);
227
+ }
228
+ return [...unique.values()].sort((a, b) => (b.last_seen_at || 0) - (a.last_seen_at || 0));
229
+ }
230
+
231
+ function persistProjectRegistry(file, projects, options = {}) {
232
+ const rows = normalizedProjectRows(projects, options.requiredRoots);
233
+ writeJsonSafe(file, { projects: rows }, { mode: 0o600 });
234
+ return rows;
235
+ }
236
+
237
+ function lexicalProjectRecord(ctx, nowFn) {
238
+ const root = path.resolve(ctx.root);
30
239
  return {
31
- root: ctx.root,
32
- db: ctx.dbPath,
33
- name: path.basename(ctx.root) || ctx.root,
240
+ root,
241
+ db: path.resolve(ctx.dbPath || projectDbPath(root)),
242
+ name: path.basename(root) || root,
34
243
  last_seen_at: registryTimestamp(nowFn)
35
244
  };
36
245
  }
37
246
 
38
- export function readProjectRegistry() {
39
- const file = projectRegistryPath();
247
+ export function projectRecord(ctx, nowFn) {
248
+ return normalizeProjectEntry(lexicalProjectRecord(ctx, nowFn), { requireExisting: true });
249
+ }
250
+
251
+ function readProjectRegistryFrom(file) {
40
252
  if (!fs.existsSync(file)) return [];
41
253
  try {
42
254
  const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
43
255
  const rows = Array.isArray(parsed?.projects) ? parsed.projects : [];
44
- return rows
45
- .filter((p) => p && typeof p.root === 'string')
46
- .map(normalizeProjectEntry)
47
- .filter(projectRootExists);
256
+ return normalizedProjectRows(rows.filter((p) => p && typeof p.root === 'string'))
257
+ .filter((project) => project?.[ROOT_EXISTS]);
48
258
  } catch {
49
259
  return [];
50
260
  }
51
261
  }
52
262
 
263
+ export function readProjectRegistry() {
264
+ return readProjectRegistryFrom(projectRegistryPath());
265
+ }
266
+
53
267
  export function writeProjectRegistry(projects) {
54
- const file = projectRegistryPath();
55
- fs.mkdirSync(path.dirname(file), { recursive: true });
56
- const unique = new Map();
57
- for (const project of projects) {
58
- const row = normalizeProjectEntry(project);
59
- if (!row) continue;
60
- unique.set(row.root, row);
61
- }
62
- const rows = [...unique.values()].sort((a, b) => (b.last_seen_at || 0) - (a.last_seen_at || 0));
63
- fs.writeFileSync(file, JSON.stringify({ projects: rows }, null, 2), { mode: 0o600 });
64
- fs.chmodSync(file, 0o600);
65
- return rows;
268
+ // Atomic temp+rename write so concurrent readers never see a half-written
269
+ // registry (avoids the interleaved read-modify-write clobber in bg-06).
270
+ return persistProjectRegistry(projectRegistryPath(), projects);
66
271
  }
67
272
 
68
- export function registerProject(ctx) {
69
- const rows = readProjectRegistry().filter((p) => path.resolve(p.root) !== ctx.root);
70
- rows.unshift(projectRecord(ctx));
71
- return writeProjectRegistry(rows);
273
+ const REGISTRY_WRITE_THROTTLE_SEC = 60;
274
+ const ACTIVITY_LOCK_THROTTLE_MS = 60_000;
275
+ const ACTIVITY_FAILURE_BACKOFF_MS = 250;
276
+ const ACTIVITY_CACHE_LIMIT = 1024;
277
+ const recentActivityLocks = new Map();
278
+
279
+ function updateProjectRegistry(candidate, lockOptions = {}) {
280
+ const registryPath = projectRegistryPath();
281
+ return withFileLock(registryPath, (lockedRegistryPath) => {
282
+ const existing = readProjectRegistryFrom(lockedRegistryPath);
283
+ // Web requests touch this path frequently. Suppress only an identical
284
+ // root+DB refresh; changing the DB binding must be persisted immediately.
285
+ const row = existing.find((project) => project.root === candidate.root);
286
+ // v1-05: a negative delta (future timestamp / clock step-back) must bypass
287
+ // the throttle so the registry is refreshed immediately — the original
288
+ // ageSeconds >= 0 guard already handled this correctly.
289
+ const ageSeconds = row ? registryTimestamp() - (row.last_seen_at || 0) : null;
290
+ if (row && row.db === candidate.db &&
291
+ ageSeconds >= 0 && ageSeconds < REGISTRY_WRITE_THROTTLE_SEC) {
292
+ return existing;
293
+ }
294
+ const rows = existing.filter((project) => project.root !== candidate.root);
295
+ rows.unshift(candidate);
296
+ return persistProjectRegistry(
297
+ lockedRegistryPath,
298
+ rows,
299
+ { requiredRoots: new Set([candidate.root]) }
300
+ );
301
+ }, lockOptions);
302
+ }
303
+
304
+ export function registerProject(ctx, lockOptions = {}) {
305
+ const candidate = projectRecord(ctx);
306
+ if (!candidate) throw new TypeError('project root is required');
307
+ // xx-01: the throttle check lives inside the lock (canonical-domain safe, see
308
+ // test/file-lock.test.mjs 'HOME is retargeted'). The request hot path is kept
309
+ // off the lock by rememberProject skipping projects it already resolved.
310
+ return updateProjectRegistry(candidate, lockOptions);
72
311
  }
73
312
 
74
313
  export function registerProjectActivity(ctx) {
75
- try { registerProject(ctx); } catch {}
314
+ try {
315
+ const wallTimestamp = Date.now();
316
+ const candidate = projectRecord(ctx, () => Math.floor(wallTimestamp / 1000));
317
+ if (!candidate) return;
318
+ const key = candidate.root;
319
+ const now = Number(process.hrtime.bigint() / 1_000_000n);
320
+ const previous = recentActivityLocks.get(key);
321
+ const previousWindow = previous?.succeeded
322
+ ? ACTIVITY_LOCK_THROTTLE_MS
323
+ : ACTIVITY_FAILURE_BACKOFF_MS;
324
+ if (previous?.db === candidate.db &&
325
+ wallTimestamp >= previous.wallTimestamp &&
326
+ now - previous.timestamp < previousWindow) return;
327
+
328
+ for (const [cachedKey, cached] of recentActivityLocks) {
329
+ const window = cached.succeeded
330
+ ? ACTIVITY_LOCK_THROTTLE_MS
331
+ : ACTIVITY_FAILURE_BACKOFF_MS;
332
+ if (now - cached.timestamp >= window) recentActivityLocks.delete(cachedKey);
333
+ }
334
+ if (recentActivityLocks.size >= ACTIVITY_CACHE_LIMIT) {
335
+ recentActivityLocks.delete(recentActivityLocks.keys().next().value);
336
+ }
337
+ try {
338
+ updateProjectRegistry(candidate, { nonblocking: true });
339
+ recentActivityLocks.set(key, {
340
+ db: candidate.db,
341
+ wallTimestamp,
342
+ timestamp: now,
343
+ succeeded: true
344
+ });
345
+ } catch {
346
+ // Avoid hammering the lock endpoint on every HTTP request while ensuring one
347
+ // busy attempt cannot suppress the first registry write for a minute.
348
+ recentActivityLocks.set(key, {
349
+ db: candidate.db,
350
+ wallTimestamp,
351
+ timestamp: now,
352
+ succeeded: false
353
+ });
354
+ }
355
+ } catch {}
76
356
  }