@logicseek/hello-cc 1.0.0 → 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 (35) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +32 -0
  3. package/README.zh-CN.md +31 -0
  4. package/docs/guide.md +58 -0
  5. package/docs/guide.zh-CN.md +55 -0
  6. package/lib/cli/app.mjs +2 -0
  7. package/lib/cli/commands/coordination.mjs +3 -1
  8. package/lib/cli/commands/gc.mjs +182 -32
  9. package/lib/cli/commands/misc.mjs +15 -0
  10. package/lib/cli/commands/peer.mjs +21 -5
  11. package/lib/process/identity.mjs +30 -0
  12. package/lib/runtime/buffer-directory-lease.mjs +2 -1
  13. package/lib/runtime/client.mjs +3 -0
  14. package/lib/shared/file-lock.mjs +11 -4
  15. package/lib/shared/socket-lock-worker.mjs +73 -24
  16. package/lib/terminal/tmux.mjs +53 -23
  17. package/lib/web/buffer-gc-runtime.mjs +2 -2
  18. package/lib/web/project-contexts.mjs +6 -1
  19. package/lib/web/runtime-main.mjs +3 -0
  20. package/lib/web/startup.mjs +4 -2
  21. package/package.json +2 -2
  22. package/scripts/regression.mjs +95 -98
  23. package/docs/architecture-split-review.md +0 -217
  24. package/docs/current-state-review-2026-08.md +0 -341
  25. package/docs/defect-review-2026-07.md +0 -382
  26. package/docs/layout-split-stack-audit.md +0 -298
  27. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
  28. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
  29. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
  30. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
  31. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
  32. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
  33. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
  34. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
  35. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +0 -104
package/CHANGELOG.md CHANGED
@@ -16,6 +16,83 @@ the release description from the current changelog section. Use
16
16
  `npm run release:github` with `GH_TOKEN` or `GITHUB_TOKEN` only for local
17
17
  backfills.
18
18
 
19
+ ## 1.0.1
20
+
21
+ ### Summary
22
+
23
+ hello-cc 1.0.1 is a file-lock determinism, buffer-GC safety, fresh-install, and
24
+ release-reliability patch. It makes worker shutdown reclaim kernel lock
25
+ endpoints deterministically when an identity probe keeps its client write half
26
+ open, restores real PTY startup in fresh macOS npm installations, and keeps
27
+ automatic GC fail-closed when optional buffer paths disappear or change.
28
+
29
+ ### Highlights
30
+
31
+ - The socket-lock worker now tracks each locally accepted socket and destroys
32
+ those accepted handles during server shutdown, so release no longer depends
33
+ on the peer closing its writable half after reading the lock banner.
34
+ - A real half-open identity-probe regression verifies that release returns
35
+ promptly, a nonblocking reacquisition succeeds immediately, and the probe is
36
+ explicitly stopped and awaited during final cleanup.
37
+ - `node-pty` is exactly pinned to `1.2.0-beta.15`. The stable `node-pty 1.1.0`
38
+ npm artifact contains non-executable Darwin helpers, which makes a fresh
39
+ macOS install fail with `posix_spawnp failed`; the selected artifact publishes
40
+ both Darwin helpers with executable modes.
41
+ - Linux, macOS, and WSL installation instructions now list Node.js 24, tmux
42
+ commands for the major system package managers, CLI verification, and safe
43
+ handling for npm global-install permission errors.
44
+ - `hcc down` now waits for the exact Runtime process identity to exit before it
45
+ reports success, including safe handling for PID reuse and a bounded timeout
46
+ when shutdown cannot be confirmed.
47
+ - Background Web startup keeps its immediate child-exit failure but allows a
48
+ bounded 60-second health window, avoiding false failures on loaded macOS CI
49
+ hosts where process identity and project restoration can exceed 30 seconds.
50
+ - Manual buffer GC allows a bounded 30-second window for runtime evidence
51
+ planning and apply, while still deferring all eligible files if the runtime
52
+ remains unavailable.
53
+ - Automatic GC now treats a missing `.hello-cc/bufs` leaf as empty without
54
+ recreating it. A missing state directory, a symlinked path component, an
55
+ inode replacement, or a retargeted project-root alias fails closed with
56
+ `PROJECT_PATH_FORBIDDEN` before buffer, clock, lock, or history mutation.
57
+ - Buffer path identity is retained through evidence collection, lease
58
+ acquisition, planning, clock reads, and database transactions. GC uses a
59
+ no-create lease while existing file-lock callers retain their original
60
+ parent-creation behavior.
61
+ - Per-connection Web action tokens now require their issuing terminal socket to
62
+ still be open, closing the handshake race before asynchronous token cleanup.
63
+ - Runtime request deadlines now reach the underlying HTTP transport, and peer
64
+ start/attach preflight and mutation requests use a bounded 60-second window
65
+ while stop and terminal-input operations use 30 seconds, instead of all
66
+ inheriting the shorter read-request default.
67
+
68
+ ### Compatibility Notes
69
+
70
+ - No CLI command or package-surface behavior changes are intended. Schema v7,
71
+ Runtime API v2, the authenticated browser's ability to choose any existing
72
+ server directory as a project root, and the 1.0.0 accepted-risk boundaries
73
+ are unchanged.
74
+ - Stable project-root aliases remain supported. Only path disappearance,
75
+ replacement, symlink traversal, or alias retargeting during GC is rejected.
76
+ - The existing five-second fail-closed deadline and cleanup-error behavior are
77
+ unchanged.
78
+ - `node-pty@1.2.0-beta.15` is a bounded prerelease dependency risk. The exact
79
+ pin prevents unreviewed beta movement, and release tests verify the installed
80
+ version, regular-file identity, executable modes, and a real PTY. There is no
81
+ postinstall or CI-only `chmod` workaround.
82
+
83
+ ### Validation
84
+
85
+ The 1.0.1 release gate includes repeated macOS Node 24 tests; a no-cache Linux
86
+ Node 24/tmux image build followed by three complete `npm test` runs in that same
87
+ image, each ending with `FULL_REGRESSION_OK`; and a fourth clean container that
88
+ installs the same 1.0.1 package tarball and passes PTY, database, Web, and
89
+ missing-buffer GC smoke checks. Fresh macOS consumer installs pass both normal
90
+ and `--ignore-scripts` installation, proving that executable helper modes come
91
+ from the package artifact rather than lifecycle-script mutation. GC regression
92
+ coverage includes absent optional leaves, symlink and ancestor replacement,
93
+ alias retargeting, no-create lock acquisition, and path guards around clock and
94
+ history writes.
95
+
19
96
  ## 1.0.0
20
97
 
21
98
  ### Summary
package/README.md CHANGED
@@ -70,10 +70,42 @@ currently supported; use WSL for a Linux-like environment.
70
70
  Linux has richer process auto-discovery through `/proc`. On macOS, use
71
71
  hello-cc shims or `hcc peer start` for reliable tmux-managed terminal sessions.
72
72
 
73
+ Install Node.js 24 or newer from the official Node.js packages or a Node
74
+ version manager. Then install `tmux` for your platform:
75
+
76
+ ```bash
77
+ # Debian / Ubuntu
78
+ sudo apt-get update && sudo apt-get install -y tmux
79
+ # Fedora / RHEL
80
+ sudo dnf install -y tmux
81
+ # Older RHEL / CentOS
82
+ sudo yum install -y tmux
83
+ # Alpine
84
+ sudo apk add tmux
85
+ # Arch Linux
86
+ sudo pacman -S --needed tmux
87
+ # openSUSE
88
+ sudo zypper install tmux
89
+ # macOS only
90
+ brew install tmux
91
+ ```
92
+
93
+ On Linux, use the distribution package manager; Homebrew is only the macOS
94
+ command above. Root shells can omit `sudo`. In WSL, run the matching Linux
95
+ distribution commands inside WSL. Then install and verify hello-cc:
96
+
73
97
  ```bash
74
98
  npm install -g @logicseek/hello-cc
99
+ node --version
100
+ npm --version
101
+ tmux -V
102
+ hcc --version
103
+ hcc --help
75
104
  ```
76
105
 
106
+ If npm reports `EACCES`, use a Node version manager or a user-owned npm prefix;
107
+ do not work around it with `sudo npm install -g`.
108
+
77
109
  Update an existing global install:
78
110
 
79
111
  ```bash
package/README.zh-CN.md CHANGED
@@ -57,10 +57,41 @@ hello-cc 支持 Linux 和 macOS。需要 Node.js 24 或更新版本;可被浏
57
57
  Linux 可以通过 `/proc` 做更完整的进程自动发现。macOS 上建议通过 hello-cc
58
58
  shim 或 `hcc peer start` 启动会话,以获得可靠的 tmux 托管终端。
59
59
 
60
+ 先通过 Node.js 官方软件包或 Node 版本管理器安装 Node.js 24 或更新版本,再按
61
+ 系统安装 `tmux`:
62
+
63
+ ```bash
64
+ # Debian / Ubuntu
65
+ sudo apt-get update && sudo apt-get install -y tmux
66
+ # Fedora / RHEL
67
+ sudo dnf install -y tmux
68
+ # 旧版 RHEL / CentOS
69
+ sudo yum install -y tmux
70
+ # Alpine
71
+ sudo apk add tmux
72
+ # Arch Linux
73
+ sudo pacman -S --needed tmux
74
+ # openSUSE
75
+ sudo zypper install tmux
76
+ # 仅 macOS
77
+ brew install tmux
78
+ ```
79
+
80
+ Linux 必须使用当前发行版的系统包管理器,而不是 Homebrew。root shell 可以省略
81
+ `sudo`。WSL 用户应在 WSL 内部执行对应 Linux 发行版的命令。然后安装并验证:
82
+
60
83
  ```bash
61
84
  npm install -g @logicseek/hello-cc
85
+ node --version
86
+ npm --version
87
+ tmux -V
88
+ hcc --version
89
+ hcc --help
62
90
  ```
63
91
 
92
+ 如果 npm 报 `EACCES`,请使用 Node 版本管理器或用户自有的 npm prefix,不要用
93
+ `sudo npm install -g` 绕过权限问题。
94
+
64
95
  更新已有的全局安装:
65
96
 
66
97
  ```bash
package/docs/guide.md CHANGED
@@ -40,12 +40,70 @@ the supported downgrade contract.
40
40
 
41
41
  ## Install, Update, And Uninstall
42
42
 
43
+ ### Prerequisites
44
+
45
+ Install Node.js 24 or newer from the official Node.js packages or a Node
46
+ version manager. Browser-controllable terminals also require `tmux`:
47
+
48
+ ```bash
49
+ # Debian / Ubuntu
50
+ sudo apt-get update
51
+ sudo apt-get install -y tmux
52
+ # Fedora / RHEL
53
+ sudo dnf install -y tmux
54
+ # Older RHEL / CentOS
55
+ sudo yum install -y tmux
56
+ # Alpine
57
+ sudo apk add tmux
58
+ # Arch Linux
59
+ sudo pacman -S --needed tmux
60
+ # openSUSE
61
+ sudo zypper install tmux
62
+ # macOS only
63
+ brew install tmux
64
+ ```
65
+
66
+ On Linux, use the distribution package manager, not Homebrew. A root shell can
67
+ omit `sudo`. WSL follows the matching Linux distribution instructions inside
68
+ WSL; native Windows shells are not supported.
69
+
70
+ `hcc web` can attempt to install tmux when a supported package manager and
71
+ sufficient privileges are available. Manual installation is the deterministic
72
+ choice for servers, containers, and non-interactive sudo configurations.
73
+
74
+ ### Install And Verify
75
+
43
76
  Install hello-cc globally:
44
77
 
45
78
  ```bash
46
79
  npm install -g @logicseek/hello-cc
47
80
  ```
48
81
 
82
+ If npm reports `EACCES`, use a Node version manager or configure a user-owned
83
+ npm prefix. Do not use `sudo npm install -g`, because it can create root-owned
84
+ package and configuration files.
85
+
86
+ Verify every prerequisite and the installed CLI:
87
+
88
+ ```bash
89
+ node --version
90
+ npm --version
91
+ tmux -V
92
+ hcc --version
93
+ hcc --help
94
+ ```
95
+
96
+ The Node version must start with `v24` or a newer major. Every command above
97
+ must exit successfully before starting a managed terminal.
98
+
99
+ To avoid a global install, use:
100
+
101
+ ```bash
102
+ npx @logicseek/hello-cc web
103
+ ```
104
+
105
+ ### Update And Uninstall
106
+
49
107
  Update an existing global install:
50
108
 
51
109
  ```bash
@@ -36,12 +36,67 @@ v1 会把恢复的数据库重新迁移到 schema v7;用旧版本 hello-cc 打
36
36
 
37
37
  ## 安装、更新和卸载
38
38
 
39
+ ### 前置依赖
40
+
41
+ 通过 Node.js 官方软件包或 Node 版本管理器安装 Node.js 24 或更新版本。浏览器可
42
+ 控制的终端还需要 `tmux`:
43
+
44
+ ```bash
45
+ # Debian / Ubuntu
46
+ sudo apt-get update
47
+ sudo apt-get install -y tmux
48
+ # Fedora / RHEL
49
+ sudo dnf install -y tmux
50
+ # 旧版 RHEL / CentOS
51
+ sudo yum install -y tmux
52
+ # Alpine
53
+ sudo apk add tmux
54
+ # Arch Linux
55
+ sudo pacman -S --needed tmux
56
+ # openSUSE
57
+ sudo zypper install tmux
58
+ # 仅 macOS
59
+ brew install tmux
60
+ ```
61
+
62
+ Linux 必须使用当前发行版的系统包管理器,而不是 Homebrew。root shell 可以省略
63
+ `sudo`。WSL 用户应在 WSL 内部按对应 Linux 发行版安装;不支持原生 Windows
64
+ shell。
65
+
66
+ 当存在受支持的包管理器且权限足够时,`hcc web` 可以尝试自动安装 tmux。对于
67
+ 服务器、容器和非交互式 sudo 环境,手动安装是确定性更高的方式。
68
+
69
+ ### 安装和验证
70
+
39
71
  全局安装 hello-cc:
40
72
 
41
73
  ```bash
42
74
  npm install -g @logicseek/hello-cc
43
75
  ```
44
76
 
77
+ 如果 npm 报 `EACCES`,请使用 Node 版本管理器或配置用户自有的 npm prefix。
78
+ 不要使用 `sudo npm install -g`,否则可能产生 root 所有的包和配置文件。
79
+
80
+ 验证全部前置依赖和 CLI:
81
+
82
+ ```bash
83
+ node --version
84
+ npm --version
85
+ tmux -V
86
+ hcc --version
87
+ hcc --help
88
+ ```
89
+
90
+ Node 版本应为 `v24` 开头或更高主版本;启动托管终端前,上述命令都应成功退出。
91
+
92
+ 不全局安装时可以使用:
93
+
94
+ ```bash
95
+ npx @logicseek/hello-cc web
96
+ ```
97
+
98
+ ### 更新和卸载
99
+
45
100
  更新已有的全局安装:
46
101
 
47
102
  ```bash
package/lib/cli/app.mjs CHANGED
@@ -51,6 +51,7 @@ import { readPackageMeta } from '../package-meta.mjs';
51
51
  import { removeGuidanceBlocks as removeGuidanceBlocksForRoot, writeGuidance as writeGuidanceForRoot } from '../guidance.mjs';
52
52
  import { globalRuntimePath, runtimePath } from '../runtime/paths.mjs';
53
53
  import { readRuntime, reclaimRuntimePointerFiles } from '../runtime/state.mjs';
54
+ import { waitForProcessIdentityExit } from '../process/identity.mjs';
54
55
 
55
56
  import { runtimeRequest } from '../runtime/client.mjs';
56
57
 
@@ -310,6 +311,7 @@ const { cmdDown, cmdFindRoot, cmdWhichReal } = createMiscCommands({
310
311
  path, fs, process, CliError, parseOpts, printResult,
311
312
  readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
312
313
  reclaimRuntimePointerFiles, helpDown, loadSetup,
314
+ waitForProcessIdentityExit,
313
315
  PRODUCT_NAME
314
316
  });
315
317
 
@@ -1,6 +1,8 @@
1
1
  // Coordination CLI commands extracted from bin/hcc.mjs: handoff, event,
2
2
  // heartbeat, ask, broadcast, inject. Factory pattern with injected deps.
3
3
 
4
+ import { RUNTIME_SESSION_MUTATION_TIMEOUT_MS } from '../../runtime/client.mjs';
5
+
4
6
  export function createCoordinationCommands(deps) {
5
7
  const {
6
8
  connect,
@@ -228,7 +230,7 @@ export function createCoordinationCommands(deps) {
228
230
  return runtimeRequest(ctx, 'POST', `/api/sessions/${encodeURIComponent(peer)}/input`, {
229
231
  text,
230
232
  enter
231
- }, runtime);
233
+ }, runtime, { timeoutMs: RUNTIME_SESSION_MUTATION_TIMEOUT_MS });
232
234
  }
233
235
 
234
236
  async function cmdInject(ctx, args) {
@@ -39,6 +39,8 @@ import {
39
39
  } from '../../runtime/buffer-gc.mjs';
40
40
  import { applyClockSafeBufferPlan } from '../../runtime/buffer-gc-protocol.mjs';
41
41
 
42
+ export const RUNTIME_BUFFER_GC_TIMEOUT_MS = 30_000;
43
+
42
44
  export function createGcCommands(deps) {
43
45
  const {
44
46
  connect, now, helpGc,
@@ -109,7 +111,8 @@ function observeGcClockSafety(ctx, db, {
109
111
  observedAt,
110
112
  historyCategories,
111
113
  includeStalePeers,
112
- historySnapshot
114
+ historySnapshot,
115
+ bufferPathGuard = null
113
116
  }) {
114
117
  try {
115
118
  return runOptimisticEvidenceMutation(db, {
@@ -124,10 +127,13 @@ function observeGcClockSafety(ctx, db, {
124
127
  }),
125
128
  observe: (subject) => observeGcEvidence(ctx, subject),
126
129
  same: (left, right) => JSON.stringify(left) === JSON.stringify(right),
127
- beforeMutate: (subject, evidenceByOwner) => observeClockSafetyInTransactionOrThrow(
128
- db,
129
- gcClockSafetyOptions(subject, evidenceByOwner, olderThanDays)
130
- ),
130
+ beforeMutate: (subject, evidenceByOwner) => {
131
+ if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
132
+ return observeClockSafetyInTransactionOrThrow(
133
+ db,
134
+ gcClockSafetyOptions(subject, evidenceByOwner, olderThanDays)
135
+ );
136
+ },
131
137
  changedMessage: 'GC subjects changed while clock evidence was being observed; retry',
132
138
  mutate: (subject, evidenceByOwner, observation) => ({
133
139
  ...observation,
@@ -192,6 +198,128 @@ function previewGcClockSafety(ctx, db, options) {
192
198
  }
193
199
  }
194
200
 
201
+ function bufferDirectoryPathForbidden(message, directory) {
202
+ return new CliError('PROJECT_PATH_FORBIDDEN', `${message}: ${directory}`);
203
+ }
204
+
205
+ function assertBufferDirectoryPathUnchanged(captured) {
206
+ let resolvedRoot;
207
+ try {
208
+ resolvedRoot = fs.realpathSync.native(captured.requestedRoot);
209
+ } catch (error) {
210
+ if (error?.code !== 'ENOENT') throw error;
211
+ throw bufferDirectoryPathForbidden(
212
+ 'Project root changed during buffer GC',
213
+ captured.requestedRoot
214
+ );
215
+ }
216
+ if (resolvedRoot !== captured.canonicalRoot) {
217
+ throw bufferDirectoryPathForbidden(
218
+ 'Project root changed during buffer GC',
219
+ captured.requestedRoot
220
+ );
221
+ }
222
+ for (const component of captured.components) {
223
+ let current;
224
+ try {
225
+ current = fs.lstatSync(component.path);
226
+ } catch (error) {
227
+ if (error?.code !== 'ENOENT') throw error;
228
+ throw bufferDirectoryPathForbidden(
229
+ 'Buffer directory path changed during lease acquisition',
230
+ captured.directory
231
+ );
232
+ }
233
+ if (current.isSymbolicLink() || !current.isDirectory() ||
234
+ current.dev !== component.stat.dev || current.ino !== component.stat.ino) {
235
+ throw bufferDirectoryPathForbidden(
236
+ 'Buffer directory path changed during lease acquisition',
237
+ captured.directory
238
+ );
239
+ }
240
+ }
241
+ if (!captured.exists) {
242
+ try {
243
+ fs.lstatSync(captured.directory);
244
+ } catch (error) {
245
+ if (error?.code === 'ENOENT') return;
246
+ throw error;
247
+ }
248
+ throw bufferDirectoryPathForbidden(
249
+ 'Buffer directory path changed during lease acquisition',
250
+ captured.directory
251
+ );
252
+ }
253
+ }
254
+
255
+ function captureRequiredBufferDirectoryComponent(component, description) {
256
+ const stat = fs.lstatSync(component);
257
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
258
+ throw bufferDirectoryPathForbidden(`${description} must be a real directory`, component);
259
+ }
260
+ return { path: component, stat };
261
+ }
262
+
263
+ function captureOptionalBufferDirectory(root, directoryName) {
264
+ const requestedRoot = path.resolve(root);
265
+ const canonicalRoot = fs.realpathSync.native(requestedRoot);
266
+ const stateDirectory = path.join(canonicalRoot, '.hello-cc');
267
+ const directory = path.join(stateDirectory, directoryName);
268
+ const components = [
269
+ captureRequiredBufferDirectoryComponent(canonicalRoot, 'Project root'),
270
+ captureRequiredBufferDirectoryComponent(stateDirectory, 'Project state directory')
271
+ ];
272
+
273
+ let stat;
274
+ try {
275
+ stat = fs.lstatSync(directory);
276
+ } catch (error) {
277
+ if (error?.code === 'ENOENT') {
278
+ const captured = {
279
+ requestedRoot,
280
+ canonicalRoot,
281
+ directory,
282
+ components,
283
+ exists: false
284
+ };
285
+ assertBufferDirectoryPathUnchanged(captured);
286
+ return captured;
287
+ }
288
+ throw error;
289
+ }
290
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
291
+ throw bufferDirectoryPathForbidden('Buffer directory must be a real directory', directory);
292
+ }
293
+ components.push({ path: directory, stat });
294
+ return { requestedRoot, canonicalRoot, directory, components, exists: true };
295
+ }
296
+
297
+ function collectGuardedBufferEvidence(collector, captured) {
298
+ const evidence = collector(captured.directory);
299
+ assertBufferDirectoryPathUnchanged(captured);
300
+ return evidence;
301
+ }
302
+
303
+ function withOptionalBufferDirectoryLease(captured, callback) {
304
+ assertBufferDirectoryPathUnchanged(captured);
305
+ if (!captured.exists) return null;
306
+ return withBufferDirectoryLease(captured.directory, (leasedDirectory) => {
307
+ const assertDirectoryUnchanged = () => {
308
+ assertBufferDirectoryPathUnchanged(captured);
309
+ if (fs.realpathSync.native(captured.directory) !== leasedDirectory) {
310
+ throw bufferDirectoryPathForbidden(
311
+ 'Buffer directory path changed during lease acquisition',
312
+ captured.directory
313
+ );
314
+ }
315
+ };
316
+ assertDirectoryUnchanged();
317
+ const result = callback(leasedDirectory, assertDirectoryUnchanged);
318
+ assertDirectoryUnchanged();
319
+ return result;
320
+ }, { createParent: false });
321
+ }
322
+
195
323
  function runGc(ctx, db, {
196
324
  olderThanDays = 7,
197
325
  dryRun = false,
@@ -203,6 +331,9 @@ function runGc(ctx, db, {
203
331
  history = false,
204
332
  observedAt = null
205
333
  } = {}) {
334
+ const bufferPathGuard = skipBufferFiles
335
+ ? null
336
+ : captureOptionalBufferDirectory(ctx.root, BUFS_DIR_NAME);
206
337
  const auto = scope === 'auto';
207
338
  const historyCategories = auto ? ['events'] : ['events', 'tasks', 'messages', 'handoffs'];
208
339
  const applyHistory = auto || history;
@@ -213,16 +344,19 @@ function runGc(ctx, db, {
213
344
  : gcNow * 1000 - (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400000;
214
345
  const bufferRetentionSec = (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400;
215
346
  const preparedBufferEvidence = !skipBufferFiles && collectBufferEvidenceNow
216
- ? collectBufferEvidenceNow()
347
+ ? collectGuardedBufferEvidence(collectBufferEvidenceNow, bufferPathGuard)
217
348
  : null;
218
- const bufferGcCutoffs = bufferDirectoryPaths(bufferDirectory(ctx)).flatMap((file) => {
219
- try {
220
- const stat = fs.lstatSync(file);
221
- return [Math.max(0, Math.floor(stat.mtimeMs / 1000) + bufferRetentionSec)];
222
- } catch {
223
- return [];
224
- }
225
- });
349
+ const bufferGcCutoffs = skipBufferFiles
350
+ ? []
351
+ : bufferDirectoryPaths(bufferPathGuard.directory).flatMap((file) => {
352
+ try {
353
+ const stat = fs.lstatSync(file);
354
+ return [Math.max(0, Math.floor(stat.mtimeMs / 1000) + bufferRetentionSec)];
355
+ } catch {
356
+ return [];
357
+ }
358
+ });
359
+ if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
226
360
  bufferGcCutoffs.push(...(preparedBufferEvidence?.gcCutoffs || []));
227
361
  const historySnapshot = createHistoryGcSnapshot(db, gcNow - retentionSec, {
228
362
  categories: historyCategories,
@@ -245,7 +379,8 @@ function runGc(ctx, db, {
245
379
  bufferGcCutoffs,
246
380
  historyCategories,
247
381
  gcNow,
248
- historySnapshot
382
+ historySnapshot,
383
+ bufferPathGuard
249
384
  }),
250
385
  'History GC run'
251
386
  );
@@ -266,6 +401,7 @@ function runGcWithHistorySnapshot(ctx, db, {
266
401
  historyCategories,
267
402
  gcNow,
268
403
  historySnapshot,
404
+ bufferPathGuard = null,
269
405
  clockObservation: preparedClockObservation = null,
270
406
  forceDefer = false
271
407
  }) {
@@ -275,17 +411,23 @@ function runGcWithHistorySnapshot(ctx, db, {
275
411
  observedAt: gcNow,
276
412
  historyCategories,
277
413
  includeStalePeers: !auto,
278
- historySnapshot
414
+ historySnapshot,
415
+ bufferPathGuard
279
416
  });
280
417
  const graceActive = forceDefer || clockGraceSuppressed(gcNow, clockObservation.graceUntil);
281
418
  let databaseGraceActive = graceActive;
282
419
  const beforeDatabaseMutate = () => {
420
+ if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
283
421
  if (databaseGraceActive) return false;
284
422
  try {
285
423
  const observedAt = now();
286
424
  const allowed = !clockGraceSuppressed(observedAt, readClockGraceUntil(db));
287
- if (!allowed) databaseGraceActive = true;
288
- return allowed;
425
+ if (!allowed) {
426
+ databaseGraceActive = true;
427
+ return false;
428
+ }
429
+ if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
430
+ return true;
289
431
  } catch (error) {
290
432
  if (error instanceof CliError) throw error;
291
433
  throw clockSafetyUnavailable(error);
@@ -325,14 +467,16 @@ function runGcWithHistorySnapshot(ctx, db, {
325
467
  const bufCutoffMs = Number.isFinite(bufferCutoffMs)
326
468
  ? bufferCutoffMs
327
469
  : gcNow * 1000 - (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400000;
328
- const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
329
470
  if (!graceActive && !skipBufferFiles) {
330
- const bufferResult = withBufferDirectoryLease(bufsDir, () => {
471
+ const bufferResult = withOptionalBufferDirectoryLease(bufferPathGuard, (
472
+ leasedBufsDir,
473
+ assertDirectoryUnchanged
474
+ ) => {
331
475
  const currentEvidence = collectBufferEvidenceNow
332
- ? collectBufferEvidenceNow()
476
+ ? collectGuardedBufferEvidence(collectBufferEvidenceNow, bufferPathGuard)
333
477
  : preparedBufferEvidence || { protectedPaths: protectedBuffers, unknownPaths: new Set(), gcCutoffs: [] };
334
478
  const currentPlan = planBufferFiles({
335
- directories: [bufsDir],
479
+ directories: [leasedBufsDir],
336
480
  cutoffMs: bufCutoffMs,
337
481
  protectedPaths: currentEvidence.protectedPaths,
338
482
  unknownPaths: currentEvidence.unknownPaths,
@@ -341,12 +485,16 @@ function runGcWithHistorySnapshot(ctx, db, {
341
485
  ...(currentEvidence.gcCutoffs || [])
342
486
  ]
343
487
  });
488
+ assertDirectoryUnchanged();
344
489
  const currentObservedAt = now();
345
- const currentObservation = tx(db, () => observeClockSafetyInTransaction(db, {
346
- operation: 'gc',
347
- gcCutoffs: bufferPlanGcCutoffs(currentPlan, bufferRetentionSec),
348
- nowSec: currentObservedAt
349
- }));
490
+ const currentObservation = tx(db, () => {
491
+ assertDirectoryUnchanged();
492
+ return observeClockSafetyInTransaction(db, {
493
+ operation: 'gc',
494
+ gcCutoffs: bufferPlanGcCutoffs(currentPlan, bufferRetentionSec),
495
+ nowSec: currentObservedAt
496
+ });
497
+ });
350
498
  if (clockGraceSuppressed(currentObservedAt, currentObservation.graceUntil)) {
351
499
  databaseGraceActive = true;
352
500
  return deferBufferPlan(currentPlan);
@@ -360,9 +508,11 @@ function runGcWithHistorySnapshot(ctx, db, {
360
508
  }
361
509
  return applyBufferPlan(currentPlan);
362
510
  });
363
- results.buf_files = bufferResult.deleted;
364
- results.protected_buf_files = bufferResult.protected;
365
- results.deferred_buf_files = bufferResult.deferred;
511
+ if (bufferResult !== null) {
512
+ results.buf_files = bufferResult.deleted;
513
+ results.protected_buf_files = bufferResult.protected;
514
+ results.deferred_buf_files = bufferResult.deferred;
515
+ }
366
516
  }
367
517
 
368
518
  // A clock jump can make every lock look expired at once. Preserve those rows
@@ -589,7 +739,7 @@ async function prepareManualBufferGc(ctx, db, { retentionSec, dryRun }) {
589
739
  phase: 'prepare',
590
740
  retentionSec,
591
741
  dryRun
592
- }, runtime, { timeoutMs: 5000 });
742
+ }, runtime, { timeoutMs: RUNTIME_BUFFER_GC_TIMEOUT_MS });
593
743
  return {
594
744
  mode: 'runtime',
595
745
  runtime,
@@ -668,7 +818,7 @@ async function applyManualBufferGc(ctx, db, prepared, { dryRun, graceActive }) {
668
818
  const applied = await runtimeRequest(ctx, 'POST', '/api/runtime/gc-buffers', {
669
819
  phase: 'apply',
670
820
  token: prepared.token
671
- }, prepared.runtime, { timeoutMs: 5000 });
821
+ }, prepared.runtime, { timeoutMs: RUNTIME_BUFFER_GC_TIMEOUT_MS });
672
822
  const counts = requireBufferGcResult(applied);
673
823
  if (typeof applied?.complete !== 'boolean' || typeof applied?.graceActive !== 'boolean') {
674
824
  throw new CliError('RUNTIME_BAD_RESPONSE', 'Runtime buffer GC returned invalid completion state');