@logicseek/hello-cc 0.1.8 → 1.0.0

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 (108) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +26 -4
  3. package/README.zh-CN.md +22 -3
  4. package/bin/hcc.mjs +3 -5622
  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 +16 -5
  10. package/docs/commands.zh-CN.md +16 -5
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +53 -5
  14. package/docs/guide.zh-CN.md +46 -3
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +15 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +139 -15
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +61 -8
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +232 -29
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +143 -7
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5646 -262
@@ -0,0 +1,852 @@
1
+ // Garbage collection extracted from bin/hcc.mjs.
2
+ // Clock-safe GC: lock/history/buffer planning with fencing evidence, runtime
3
+ // arbitration for buffer files, and the manual hcc gc command.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { CliError } from '../../shared/errors.mjs';
8
+ import { tx } from '../../db/schema.mjs';
9
+ import { parseOpts, validateOpts, wantsHelp } from '../../cli-args.mjs';
10
+ import { printResult } from '../../format.mjs';
11
+ import { readClockGraceUntil, clockGraceSuppressed } from '../../shared/clock-grace.mjs';
12
+ import {
13
+ clockSafetyUnavailable,
14
+ observeClockSafetyInTransaction,
15
+ previewClockSafety
16
+ } from '../../core/coordination/clock-safety.mjs';
17
+ import {
18
+ captureGcLockSubjects,
19
+ captureHistoryGcPlan,
20
+ createHistoryGcSnapshot,
21
+ finalizeGcLockSubjects,
22
+ finalizeHistoryGcBatches,
23
+ runWithHistoryGcSnapshotCleanup,
24
+ runWithHistoryGcSnapshotCleanupAsync
25
+ } from '../../core/coordination/gc-plan.mjs';
26
+ import { runOptimisticEvidenceMutation } from '../../core/coordination/optimistic-evidence.mjs';
27
+ import { peerEvidenceAllowsReap } from '../../core/peers/evidence.mjs';
28
+ import { runtimePath, globalRuntimePath } from '../../runtime/paths.mjs';
29
+ import { readRuntime, reclaimRuntimePointerFiles } from '../../runtime/state.mjs';
30
+ import { runtimeRequest, runtimeBufferGcUnavailable } from '../../runtime/client.mjs';
31
+ import { collectBufferEvidence } from '../../runtime/buffer-evidence.mjs';
32
+ import { withBufferDirectoryLease } from '../../runtime/buffer-directory-lease.mjs';
33
+ import {
34
+ applyBufferPlan,
35
+ bufferPlanGcCutoffs,
36
+ deferBufferPlan,
37
+ planBufferFiles,
38
+ pruneBufferFiles
39
+ } from '../../runtime/buffer-gc.mjs';
40
+ import { applyClockSafeBufferPlan } from '../../runtime/buffer-gc-protocol.mjs';
41
+
42
+ export function createGcCommands(deps) {
43
+ const {
44
+ connect, now, helpGc,
45
+ UNKNOWN_EVIDENCE_GRACE_SEC, BUFS_DIR_NAME,
46
+ peerMutationSubject, mutatePeerWithEvidence,
47
+ observeClockSafetyInTransactionOrThrow, observePeerEvidence
48
+ } = deps;
49
+
50
+ // ─── hcc gc ───────────────────────────────────────────────────────────────────
51
+
52
+ // Prune stale state. scope='full' always plans technical and history state, but
53
+ // manual history is applied only with `hcc gc --history --yes`.
54
+ // scope='auto' cleans only high-volume/ephemeral items (events, expired locks,
55
+ // stale bufs) so automatic background gc bounds DB growth without deleting
56
+ // user-meaningful history.
57
+ // `db` is used open; the caller owns its lifecycle.
58
+ function captureGcClockSubject(db, {
59
+ bufferGcCutoffs,
60
+ olderThanDays,
61
+ observedAt,
62
+ historyCategories,
63
+ includeStalePeers,
64
+ historySnapshot
65
+ }) {
66
+ const retentionSec = olderThanDays * 86400;
67
+ const cutoff = observedAt - retentionSec;
68
+ const lockSubjects = captureGcLockSubjects(db, observedAt);
69
+ const historyPlan = captureHistoryGcPlan(db, cutoff, {
70
+ categories: historyCategories,
71
+ snapshot: historySnapshot
72
+ });
73
+
74
+ const stalePeers = includeStalePeers
75
+ ? db.prepare(`
76
+ SELECT p.id
77
+ FROM peers p
78
+ WHERE p.last_seen_at <= ?
79
+ ORDER BY p.id ASC
80
+ `).all(cutoff).map(({ id }) => peerMutationSubject(db, id))
81
+ : [];
82
+
83
+ const ownerIds = new Set(lockSubjects.map(({ lock }) => lock.owner));
84
+ for (const subject of stalePeers) {
85
+ if (subject.peer?.id) ownerIds.add(subject.peer.id);
86
+ }
87
+ const owners = [...ownerIds].sort().map((owner) => ({
88
+ owner,
89
+ ...peerMutationSubject(db, owner)
90
+ }));
91
+
92
+ return {
93
+ technicalPlan: {
94
+ lockSubjects,
95
+ stalePeers
96
+ },
97
+ owners,
98
+ historyPlan,
99
+ gcCutoffs: [...historySnapshot.gcCutoffs, ...bufferGcCutoffs],
100
+ graceUntil: readClockGraceUntil(db),
101
+ olderThanDays,
102
+ observedAt
103
+ };
104
+ }
105
+
106
+ function observeGcClockSafety(ctx, db, {
107
+ bufferGcCutoffs,
108
+ olderThanDays,
109
+ observedAt,
110
+ historyCategories,
111
+ includeStalePeers,
112
+ historySnapshot
113
+ }) {
114
+ try {
115
+ return runOptimisticEvidenceMutation(db, {
116
+ attempts: 5,
117
+ capture: (subjectDb) => captureGcClockSubject(subjectDb, {
118
+ bufferGcCutoffs,
119
+ olderThanDays,
120
+ observedAt,
121
+ historyCategories,
122
+ includeStalePeers,
123
+ historySnapshot
124
+ }),
125
+ observe: (subject) => observeGcEvidence(ctx, subject),
126
+ same: (left, right) => JSON.stringify(left) === JSON.stringify(right),
127
+ beforeMutate: (subject, evidenceByOwner) => observeClockSafetyInTransactionOrThrow(
128
+ db,
129
+ gcClockSafetyOptions(subject, evidenceByOwner, olderThanDays)
130
+ ),
131
+ changedMessage: 'GC subjects changed while clock evidence was being observed; retry',
132
+ mutate: (subject, evidenceByOwner, observation) => ({
133
+ ...observation,
134
+ subject,
135
+ evidenceByOwner,
136
+ expiredLockCount: subject.technicalPlan.lockSubjects.length
137
+ })
138
+ });
139
+ } catch (err) {
140
+ if (err instanceof CliError) throw err;
141
+ throw clockSafetyUnavailable(err);
142
+ }
143
+ }
144
+
145
+ function observeGcEvidence(ctx, subject) {
146
+ return new Map(subject.owners.map(({ owner, peer, binding }) => [
147
+ owner,
148
+ peer
149
+ ? observePeerEvidence(ctx, peer, binding)
150
+ : { state: 'unknown', reason: 'peer_missing' }
151
+ ]));
152
+ }
153
+
154
+ function gcClockSafetyOptions(subject, evidenceByOwner, olderThanDays) {
155
+ return {
156
+ operation: 'gc',
157
+ candidates: [
158
+ ...subject.technicalPlan.lockSubjects.map(({ lock }) => ({
159
+ boundary: Number(lock.expires_at),
160
+ evidence: evidenceByOwner.get(lock.owner)?.state || 'unknown',
161
+ owner: lock.owner,
162
+ resource: lock.resource
163
+ })),
164
+ ...subject.technicalPlan.stalePeers.map(({ peer }) => ({
165
+ boundary: Number(peer.last_seen_at) + olderThanDays * 86400,
166
+ evidence: evidenceByOwner.get(peer.id)?.state || 'unknown',
167
+ owner: peer.id
168
+ }))
169
+ ],
170
+ gcCutoffs: subject.gcCutoffs,
171
+ nowSec: subject.observedAt
172
+ };
173
+ }
174
+
175
+ function previewGcClockSafety(ctx, db, options) {
176
+ try {
177
+ const subject = captureGcClockSubject(db, options);
178
+ const evidenceByOwner = observeGcEvidence(ctx, subject);
179
+ const observation = previewClockSafety(
180
+ db,
181
+ gcClockSafetyOptions(subject, evidenceByOwner, options.olderThanDays)
182
+ );
183
+ return {
184
+ ...observation,
185
+ subject,
186
+ evidenceByOwner,
187
+ expiredLockCount: subject.technicalPlan.lockSubjects.length
188
+ };
189
+ } catch (error) {
190
+ if (error instanceof CliError) throw error;
191
+ throw clockSafetyUnavailable(error);
192
+ }
193
+ }
194
+
195
+ function runGc(ctx, db, {
196
+ olderThanDays = 7,
197
+ dryRun = false,
198
+ scope = 'full',
199
+ protectedBufFiles = null,
200
+ collectBufferEvidenceNow = null,
201
+ bufferCutoffMs = null,
202
+ skipBufferFiles = false,
203
+ history = false,
204
+ observedAt = null
205
+ } = {}) {
206
+ const auto = scope === 'auto';
207
+ const historyCategories = auto ? ['events'] : ['events', 'tasks', 'messages', 'handoffs'];
208
+ const applyHistory = auto || history;
209
+ const gcNow = Number.isSafeInteger(observedAt) && observedAt >= 0 ? observedAt : now();
210
+ const retentionSec = olderThanDays * 86400;
211
+ const fixedBufferCutoffMs = Number.isFinite(bufferCutoffMs)
212
+ ? bufferCutoffMs
213
+ : gcNow * 1000 - (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400000;
214
+ const bufferRetentionSec = (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400;
215
+ const preparedBufferEvidence = !skipBufferFiles && collectBufferEvidenceNow
216
+ ? collectBufferEvidenceNow()
217
+ : 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
+ });
226
+ bufferGcCutoffs.push(...(preparedBufferEvidence?.gcCutoffs || []));
227
+ const historySnapshot = createHistoryGcSnapshot(db, gcNow - retentionSec, {
228
+ categories: historyCategories,
229
+ retentionSec
230
+ });
231
+ return runWithHistoryGcSnapshotCleanup(
232
+ db,
233
+ historySnapshot,
234
+ () => runGcWithHistorySnapshot(ctx, db, {
235
+ olderThanDays,
236
+ dryRun,
237
+ auto,
238
+ applyHistory,
239
+ protectedBufFiles,
240
+ collectBufferEvidenceNow,
241
+ preparedBufferEvidence,
242
+ bufferCutoffMs: fixedBufferCutoffMs,
243
+ bufferRetentionSec,
244
+ skipBufferFiles,
245
+ bufferGcCutoffs,
246
+ historyCategories,
247
+ gcNow,
248
+ historySnapshot
249
+ }),
250
+ 'History GC run'
251
+ );
252
+ }
253
+
254
+ function runGcWithHistorySnapshot(ctx, db, {
255
+ olderThanDays,
256
+ dryRun,
257
+ auto,
258
+ applyHistory,
259
+ protectedBufFiles,
260
+ collectBufferEvidenceNow,
261
+ preparedBufferEvidence,
262
+ bufferCutoffMs,
263
+ bufferRetentionSec,
264
+ skipBufferFiles,
265
+ bufferGcCutoffs,
266
+ historyCategories,
267
+ gcNow,
268
+ historySnapshot,
269
+ clockObservation: preparedClockObservation = null,
270
+ forceDefer = false
271
+ }) {
272
+ const clockObservation = preparedClockObservation || observeGcClockSafety(ctx, db, {
273
+ bufferGcCutoffs,
274
+ olderThanDays,
275
+ observedAt: gcNow,
276
+ historyCategories,
277
+ includeStalePeers: !auto,
278
+ historySnapshot
279
+ });
280
+ const graceActive = forceDefer || clockGraceSuppressed(gcNow, clockObservation.graceUntil);
281
+ let databaseGraceActive = graceActive;
282
+ const beforeDatabaseMutate = () => {
283
+ if (databaseGraceActive) return false;
284
+ try {
285
+ const observedAt = now();
286
+ const allowed = !clockGraceSuppressed(observedAt, readClockGraceUntil(db));
287
+ if (!allowed) databaseGraceActive = true;
288
+ return allowed;
289
+ } catch (error) {
290
+ if (error instanceof CliError) throw error;
291
+ throw clockSafetyUnavailable(error);
292
+ }
293
+ };
294
+ const protectedBuffers = protectedBufFiles instanceof Set
295
+ ? protectedBufFiles
296
+ : new Set(protectedBufFiles || []);
297
+ const results = {
298
+ buf_files: 0,
299
+ protected_buf_files: 0,
300
+ deferred_buf_files: 0,
301
+ stale_peers: 0,
302
+ protected_stale_peers: 0,
303
+ deferred_stale_peers: 0,
304
+ old_events: 0,
305
+ old_tasks: 0,
306
+ old_messages: 0,
307
+ old_handoffs: 0,
308
+ protected_old_events: 0,
309
+ protected_old_tasks: 0,
310
+ protected_old_messages: 0,
311
+ protected_old_handoffs: Number(historySnapshot.protectedCounts?.handoffs || 0),
312
+ deferred_old_events: 0,
313
+ deferred_old_tasks: 0,
314
+ deferred_old_messages: 0,
315
+ deferred_old_handoffs: 0,
316
+ expired_locks: 0,
317
+ protected_expired_locks: 0,
318
+ deferred_expired_locks: graceActive ? clockObservation.expiredLockCount : 0,
319
+ deferred_unknown_peers: 0,
320
+ deferred_history: 0,
321
+ deferred_age_based: graceActive
322
+ };
323
+
324
+ // Buffer files (high-volume ephemeral). Auto keeps a shorter 7-day window.
325
+ const bufCutoffMs = Number.isFinite(bufferCutoffMs)
326
+ ? bufferCutoffMs
327
+ : gcNow * 1000 - (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400000;
328
+ const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
329
+ if (!graceActive && !skipBufferFiles) {
330
+ const bufferResult = withBufferDirectoryLease(bufsDir, () => {
331
+ const currentEvidence = collectBufferEvidenceNow
332
+ ? collectBufferEvidenceNow()
333
+ : preparedBufferEvidence || { protectedPaths: protectedBuffers, unknownPaths: new Set(), gcCutoffs: [] };
334
+ const currentPlan = planBufferFiles({
335
+ directories: [bufsDir],
336
+ cutoffMs: bufCutoffMs,
337
+ protectedPaths: currentEvidence.protectedPaths,
338
+ unknownPaths: currentEvidence.unknownPaths,
339
+ evidenceGcCutoffs: [
340
+ ...(preparedBufferEvidence?.gcCutoffs || []),
341
+ ...(currentEvidence.gcCutoffs || [])
342
+ ]
343
+ });
344
+ const currentObservedAt = now();
345
+ const currentObservation = tx(db, () => observeClockSafetyInTransaction(db, {
346
+ operation: 'gc',
347
+ gcCutoffs: bufferPlanGcCutoffs(currentPlan, bufferRetentionSec),
348
+ nowSec: currentObservedAt
349
+ }));
350
+ if (clockGraceSuppressed(currentObservedAt, currentObservation.graceUntil)) {
351
+ databaseGraceActive = true;
352
+ return deferBufferPlan(currentPlan);
353
+ }
354
+ if (dryRun) {
355
+ return {
356
+ deleted: currentPlan.deleteEntries.length,
357
+ protected: currentPlan.protectedEntries.length,
358
+ deferred: currentPlan.unknownEntries.length
359
+ };
360
+ }
361
+ return applyBufferPlan(currentPlan);
362
+ });
363
+ results.buf_files = bufferResult.deleted;
364
+ results.protected_buf_files = bufferResult.protected;
365
+ results.deferred_buf_files = bufferResult.deferred;
366
+ }
367
+
368
+ // A clock jump can make every lock look expired at once. Preserve those rows
369
+ // for the grace window so acquisition and heartbeat logic can recover them.
370
+ const expiredLockSubjects = clockObservation.subject.technicalPlan.lockSubjects;
371
+ if (graceActive) {
372
+ results.deferred_expired_locks = Math.max(results.deferred_expired_locks, expiredLockSubjects.length);
373
+ } else {
374
+ const lockResult = finalizeGcLockSubjects(
375
+ db,
376
+ expiredLockSubjects,
377
+ clockObservation.evidenceByOwner,
378
+ {
379
+ dryRun,
380
+ beforeMutate: dryRun ? undefined : beforeDatabaseMutate
381
+ }
382
+ );
383
+ results.expired_locks = lockResult.deleted;
384
+ results.protected_expired_locks = lockResult.live;
385
+ results.deferred_expired_locks += lockResult.deferred;
386
+ }
387
+
388
+ if (!auto && databaseGraceActive) {
389
+ results.deferred_stale_peers = clockObservation.subject.technicalPlan.stalePeers.length;
390
+ } else if (!auto) {
391
+ // Stale peers (no heartbeat in N days)
392
+ const stalePeers = clockObservation.subject.technicalPlan.stalePeers;
393
+ for (const subject of stalePeers) {
394
+ const p = subject.peer;
395
+ const evidence = clockObservation.evidenceByOwner.get(p.id) || { state: 'unknown' };
396
+ if (evidence.state === 'live') {
397
+ results.protected_stale_peers++;
398
+ continue;
399
+ }
400
+ if (evidence.state === 'unknown' && !peerEvidenceAllowsReap(evidence, {
401
+ nowSec: gcNow,
402
+ lastSeenAt: Number(p.last_seen_at || 0),
403
+ staleAfterSec: UNKNOWN_EVIDENCE_GRACE_SEC,
404
+ graceUntil: clockObservation.graceUntil
405
+ })) {
406
+ results.deferred_unknown_peers++;
407
+ results.deferred_stale_peers++;
408
+ continue;
409
+ }
410
+ if (dryRun) {
411
+ results.stale_peers++;
412
+ continue;
413
+ }
414
+ if (databaseGraceActive) {
415
+ results.deferred_stale_peers++;
416
+ continue;
417
+ }
418
+ const removed = mutatePeerWithEvidence(db, ctx, p.id, () => {
419
+ db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(p.id);
420
+ db.prepare('DELETE FROM peers WHERE id = ?').run(p.id);
421
+ }, {
422
+ acceptEvidence: (currentEvidence, currentSubject) => peerEvidenceAllowsReap(currentEvidence, {
423
+ nowSec: now(),
424
+ lastSeenAt: Number(currentSubject.peer.last_seen_at || 0),
425
+ staleAfterSec: UNKNOWN_EVIDENCE_GRACE_SEC,
426
+ graceUntil: readClockGraceUntil(db)
427
+ }),
428
+ beforeMutate: beforeDatabaseMutate
429
+ });
430
+ if (removed.changed) results.stale_peers++;
431
+ else if (removed.evidence?.state === 'live') results.protected_stale_peers++;
432
+ else {
433
+ if (removed.evidence?.state === 'unknown') results.deferred_unknown_peers++;
434
+ results.deferred_stale_peers++;
435
+ }
436
+ }
437
+
438
+ }
439
+
440
+ const eligibleHistory = historySnapshot.eligibleCounts;
441
+ if (databaseGraceActive) {
442
+ results.deferred_old_events = Number(eligibleHistory.events || 0);
443
+ results.deferred_old_tasks = Number(eligibleHistory.tasks || 0);
444
+ results.deferred_old_messages = Number(eligibleHistory.messages || 0);
445
+ results.deferred_old_handoffs = Number(eligibleHistory.handoffs || 0);
446
+ results.deferred_history = results.deferred_old_events + results.deferred_old_tasks +
447
+ results.deferred_old_messages + results.deferred_old_handoffs;
448
+ } else if (!applyHistory) {
449
+ results.protected_old_events = Number(eligibleHistory.events || 0);
450
+ results.protected_old_tasks = Number(eligibleHistory.tasks || 0);
451
+ results.protected_old_messages = Number(eligibleHistory.messages || 0);
452
+ results.protected_old_handoffs += Number(eligibleHistory.handoffs || 0);
453
+ } else {
454
+ const historyResult = finalizeHistoryGcBatches(db, clockObservation.subject.historyPlan, {
455
+ dryRun,
456
+ dropSnapshot: false,
457
+ beforeMutate: dryRun ? undefined : beforeDatabaseMutate
458
+ });
459
+ results.old_events = historyResult.old_events;
460
+ results.old_tasks = historyResult.old_tasks;
461
+ results.old_messages = historyResult.old_messages;
462
+ results.old_handoffs = historyResult.old_handoffs;
463
+ results.deferred_old_events = Math.max(0, Number(eligibleHistory.events || 0) - results.old_events);
464
+ results.deferred_old_tasks = Math.max(0, Number(eligibleHistory.tasks || 0) - results.old_tasks);
465
+ results.deferred_old_messages = Math.max(0, Number(eligibleHistory.messages || 0) - results.old_messages);
466
+ results.deferred_old_handoffs = Math.max(0, Number(eligibleHistory.handoffs || 0) - results.old_handoffs);
467
+ results.deferred_history = results.deferred_old_events + results.deferred_old_tasks +
468
+ results.deferred_old_messages + results.deferred_old_handoffs;
469
+ }
470
+
471
+ results.deferred_age_based = results.deferred_age_based || databaseGraceActive;
472
+
473
+ return results;
474
+ }
475
+
476
+ function bufferDirectory(ctx) {
477
+ return path.resolve(path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME));
478
+ }
479
+
480
+ function bufferDirectoryPaths(directory) {
481
+ try {
482
+ return fs.readdirSync(directory).map((name) => path.resolve(directory, name));
483
+ } catch {
484
+ return [];
485
+ }
486
+ }
487
+
488
+ function deferEligibleBufferFiles(directory, cutoffMs) {
489
+ return pruneBufferFiles({
490
+ directories: [directory],
491
+ cutoffMs,
492
+ unknownPaths: bufferDirectoryPaths(directory),
493
+ dryRun: true
494
+ });
495
+ }
496
+
497
+ function localBufferEvidence(ctx, db, directory, unknownTracker = null, candidatePaths = null) {
498
+ return collectBufferEvidence({
499
+ directories: [directory],
500
+ projectDbs: [{ ctx, db }],
501
+ observePeer: observePeerEvidence,
502
+ unknownTracker,
503
+ candidatePaths
504
+ });
505
+ }
506
+
507
+ function runtimePointerPresent(ctx) {
508
+ return Boolean(process.env.HCC_RUNTIME_URL ||
509
+ fs.existsSync(runtimePath(ctx)) ||
510
+ fs.existsSync(globalRuntimePath()));
511
+ }
512
+
513
+ function reclaimUnavailableRuntimePointers(ctx, { dryRun = false } = {}) {
514
+ if (process.env.HCC_RUNTIME_URL) return { allowed: false, gcCutoffs: [] };
515
+ const gcCutoffs = [];
516
+ const result = reclaimRuntimePointerFiles([runtimePath(ctx), globalRuntimePath()], {
517
+ dryRun,
518
+ onReclaim: ({ gcCutoff }) => {
519
+ if (Number.isSafeInteger(gcCutoff)) gcCutoffs.push(gcCutoff);
520
+ }
521
+ });
522
+ return { allowed: !result.blocked, gcCutoffs };
523
+ }
524
+
525
+ function requireBufferGcResult(value) {
526
+ const result = {};
527
+ for (const key of ['deleted', 'protected', 'deferred']) {
528
+ const count = Number(value?.[key]);
529
+ if (!Number.isInteger(count) || count < 0) {
530
+ throw new CliError('RUNTIME_BAD_RESPONSE', `Runtime buffer GC returned an invalid ${key} count`);
531
+ }
532
+ result[key] = count;
533
+ }
534
+ return result;
535
+ }
536
+
537
+ function requirePreparedBufferGc(value, { retentionSec, dryRun }) {
538
+ const counts = requireBufferGcResult(value);
539
+ const observedAt = Number(value?.observedAt);
540
+ const cutoffMs = Number(value?.cutoffMs);
541
+ if (!Number.isSafeInteger(observedAt) || observedAt < 0 ||
542
+ !Number.isSafeInteger(cutoffMs) ||
543
+ cutoffMs !== observedAt * 1000 - retentionSec * 1000 ||
544
+ !Array.isArray(value?.gcCutoffs) || value.gcCutoffs.some((cutoff) =>
545
+ !Number.isSafeInteger(cutoff) || cutoff < 0)) {
546
+ throw new CliError('RUNTIME_BAD_RESPONSE', 'Runtime buffer GC returned invalid derived timing');
547
+ }
548
+ if ((!dryRun && (typeof value?.token !== 'string' || value.token.length === 0)) ||
549
+ (dryRun && Object.hasOwn(value || {}, 'token'))) {
550
+ throw new CliError('RUNTIME_BAD_RESPONSE', 'Runtime buffer GC returned an invalid plan token');
551
+ }
552
+ return { ...counts, observedAt, cutoffMs, gcCutoffs: [...value.gcCutoffs], token: value.token || null };
553
+ }
554
+
555
+ function deferredPreparedBufferGc(prepared, graceActive = false) {
556
+ return {
557
+ deleted: 0,
558
+ protected: prepared.protected,
559
+ deferred: prepared.deferred + prepared.deleted,
560
+ complete: false,
561
+ graceActive
562
+ };
563
+ }
564
+
565
+ async function prepareManualBufferGc(ctx, db, { retentionSec, dryRun }) {
566
+ const directory = bufferDirectory(ctx);
567
+ const unknownTracker = new Map();
568
+ const pointerGcCutoffs = [];
569
+ const recoverRuntimePointers = () => {
570
+ const recovery = reclaimUnavailableRuntimePointers(ctx, { dryRun });
571
+ pointerGcCutoffs.push(...recovery.gcCutoffs);
572
+ return recovery.allowed;
573
+ };
574
+ const pointerPresent = runtimePointerPresent(ctx);
575
+ let runtime = null;
576
+ try { runtime = readRuntime(ctx); } catch (error) {
577
+ if (pointerPresent && !recoverRuntimePointers()) {
578
+ const observedAt = now();
579
+ const cutoffMs = observedAt * 1000 - retentionSec * 1000;
580
+ const deferred = requireBufferGcResult(deferEligibleBufferFiles(directory, cutoffMs));
581
+ return { mode: 'unavailable', observedAt, cutoffMs, retentionSec, gcCutoffs: [], token: null, ...deferred };
582
+ }
583
+ if (error?.code !== 'RUNTIME_NOT_RUNNING') throw error;
584
+ }
585
+
586
+ if (runtime) {
587
+ try {
588
+ const prepared = await runtimeRequest(ctx, 'POST', '/api/runtime/gc-buffers', {
589
+ phase: 'prepare',
590
+ retentionSec,
591
+ dryRun
592
+ }, runtime, { timeoutMs: 5000 });
593
+ return {
594
+ mode: 'runtime',
595
+ runtime,
596
+ retentionSec,
597
+ ...requirePreparedBufferGc(prepared, { retentionSec, dryRun })
598
+ };
599
+ } catch (error) {
600
+ if (runtimeBufferGcUnavailable(error)) {
601
+ if (!recoverRuntimePointers()) {
602
+ const observedAt = now();
603
+ const cutoffMs = observedAt * 1000 - retentionSec * 1000;
604
+ const deferred = requireBufferGcResult(deferEligibleBufferFiles(directory, cutoffMs));
605
+ return { mode: 'unavailable', observedAt, cutoffMs, retentionSec, gcCutoffs: [], token: null, ...deferred };
606
+ }
607
+ runtime = null;
608
+ } else {
609
+ throw error;
610
+ }
611
+ }
612
+ }
613
+
614
+ const observedAt = now();
615
+ const cutoffMs = observedAt * 1000 - retentionSec * 1000;
616
+ const { protectedPaths, unknownPaths, gcCutoffs } = localBufferEvidence(ctx, db, directory, unknownTracker);
617
+ const plan = planBufferFiles({
618
+ directories: [directory],
619
+ cutoffMs,
620
+ protectedPaths,
621
+ unknownPaths,
622
+ evidenceGcCutoffs: gcCutoffs
623
+ });
624
+ return {
625
+ mode: 'local',
626
+ observedAt,
627
+ cutoffMs,
628
+ retentionSec,
629
+ gcCutoffs: [...bufferPlanGcCutoffs(plan, retentionSec), ...pointerGcCutoffs],
630
+ token: null,
631
+ plan,
632
+ directory,
633
+ unknownTracker,
634
+ deleted: plan.deleteEntries.length,
635
+ protected: plan.protectedEntries.length,
636
+ deferred: plan.unknownEntries.length
637
+ };
638
+ }
639
+
640
+ async function applyManualBufferGc(ctx, db, prepared, { dryRun, graceActive }) {
641
+ if (dryRun) {
642
+ return graceActive || prepared.mode === 'unavailable'
643
+ ? deferredPreparedBufferGc(prepared, graceActive)
644
+ : { ...requireBufferGcResult(prepared), complete: true, graceActive: false };
645
+ }
646
+ if (prepared.mode === 'unavailable') return deferredPreparedBufferGc(prepared);
647
+ if (prepared.mode === 'local') {
648
+ if (graceActive) return deferredPreparedBufferGc(prepared, true);
649
+ try {
650
+ return applyClockSafeBufferPlan({
651
+ db,
652
+ plan: prepared.plan,
653
+ retentionSec: prepared.retentionSec,
654
+ collectEvidence: ({ entries }) => localBufferEvidence(
655
+ ctx,
656
+ db,
657
+ prepared.directory,
658
+ prepared.unknownTracker,
659
+ entries.map((entry) => entry.path)
660
+ )
661
+ });
662
+ } catch (error) {
663
+ if (error instanceof CliError) throw error;
664
+ throw clockSafetyUnavailable(error);
665
+ }
666
+ }
667
+ try {
668
+ const applied = await runtimeRequest(ctx, 'POST', '/api/runtime/gc-buffers', {
669
+ phase: 'apply',
670
+ token: prepared.token
671
+ }, prepared.runtime, { timeoutMs: 5000 });
672
+ const counts = requireBufferGcResult(applied);
673
+ if (typeof applied?.complete !== 'boolean' || typeof applied?.graceActive !== 'boolean') {
674
+ throw new CliError('RUNTIME_BAD_RESPONSE', 'Runtime buffer GC returned invalid completion state');
675
+ }
676
+ return { ...counts, complete: applied.complete, graceActive: applied.graceActive };
677
+ } catch (error) {
678
+ if (runtimeBufferGcUnavailable(error)) return deferredPreparedBufferGc(prepared);
679
+ throw error;
680
+ }
681
+ }
682
+
683
+ async function runManualGc(ctx, db, {
684
+ olderThanDays,
685
+ dryRun,
686
+ history
687
+ }) {
688
+ const retentionSec = olderThanDays * 86400;
689
+ const preparedBuffer = await prepareManualBufferGc(ctx, db, { retentionSec, dryRun });
690
+ const gcNow = preparedBuffer.observedAt;
691
+ const historyCategories = ['events', 'tasks', 'messages', 'handoffs'];
692
+ const historySnapshot = createHistoryGcSnapshot(db, gcNow - retentionSec, {
693
+ categories: historyCategories,
694
+ retentionSec
695
+ });
696
+ return runWithHistoryGcSnapshotCleanupAsync(
697
+ db,
698
+ historySnapshot,
699
+ async () => {
700
+ const observationOptions = {
701
+ bufferGcCutoffs: preparedBuffer.gcCutoffs,
702
+ olderThanDays,
703
+ observedAt: gcNow,
704
+ historyCategories,
705
+ includeStalePeers: true,
706
+ historySnapshot
707
+ };
708
+ const clockObservation = dryRun
709
+ ? previewGcClockSafety(ctx, db, observationOptions)
710
+ : observeGcClockSafety(ctx, db, observationOptions);
711
+ const graceActive = clockGraceSuppressed(gcNow, clockObservation.graceUntil);
712
+ const bufferResult = await applyManualBufferGc(ctx, db, preparedBuffer, { dryRun, graceActive });
713
+ let graceBeforeDatabaseApply = false;
714
+ if (!dryRun) {
715
+ try {
716
+ const finalObservedAt = now();
717
+ graceBeforeDatabaseApply = clockGraceSuppressed(
718
+ finalObservedAt,
719
+ readClockGraceUntil(db)
720
+ );
721
+ } catch (error) {
722
+ throw clockSafetyUnavailable(error);
723
+ }
724
+ }
725
+ const forceDefer = graceActive || (!dryRun && !bufferResult.complete) ||
726
+ preparedBuffer.mode === 'unavailable' || graceBeforeDatabaseApply;
727
+ const results = runGcWithHistorySnapshot(ctx, db, {
728
+ olderThanDays,
729
+ dryRun,
730
+ auto: false,
731
+ applyHistory: history,
732
+ protectedBufFiles: null,
733
+ bufferCutoffMs: preparedBuffer.cutoffMs,
734
+ skipBufferFiles: true,
735
+ bufferGcCutoffs: preparedBuffer.gcCutoffs,
736
+ historyCategories,
737
+ gcNow,
738
+ historySnapshot,
739
+ clockObservation,
740
+ forceDefer
741
+ });
742
+ results.buf_files = bufferResult.deleted;
743
+ results.protected_buf_files = bufferResult.protected;
744
+ results.deferred_buf_files = bufferResult.deferred;
745
+ return results;
746
+ },
747
+ 'Manual GC run'
748
+ );
749
+ }
750
+
751
+ function gcRetentionDays(opts) {
752
+ const raw = opts['older-than'];
753
+ if (raw === undefined) return 7;
754
+ if (typeof raw !== 'string' || !/^(?:0|[1-9][0-9]*)$/.test(raw)) {
755
+ throw new CliError('BAD_ARGS', '--older-than must be a canonical non-negative integer');
756
+ }
757
+ const value = Number(raw);
758
+ if (!Number.isSafeInteger(value) || !Number.isSafeInteger(value * 86400000)) {
759
+ throw new CliError('BAD_ARGS', '--older-than exceeds the safe integer range');
760
+ }
761
+ return value;
762
+ }
763
+
764
+ async function cmdGc(ctx, args) {
765
+ if (wantsHelp(args)) return helpGc();
766
+ const valuedDestructiveFlag = args.find((arg) =>
767
+ typeof arg === 'string' && (/^--yes=/.test(arg) || /^--history=/.test(arg)));
768
+ if (valuedDestructiveFlag) {
769
+ throw new CliError('BAD_ARGS', `${valuedDestructiveFlag.split('=', 1)[0]} does not accept a value`);
770
+ }
771
+ const opts = parseOpts(args, { booleans: ['yes', 'force', 'history'] });
772
+ validateOpts('gc', opts, ['older-than', 'yes', 'history']);
773
+ if (opts.yes !== undefined && opts.yes !== true) {
774
+ throw new CliError('BAD_ARGS', '--yes does not accept a value');
775
+ }
776
+ if (opts.history !== undefined && opts.history !== true) {
777
+ throw new CliError('BAD_ARGS', '--history does not accept a value');
778
+ }
779
+ const olderThanDays = gcRetentionDays(opts);
780
+ const dryRun = opts.yes !== true;
781
+ const db = connect(ctx);
782
+ let results;
783
+ try {
784
+ results = await runManualGc(ctx, db, {
785
+ olderThanDays,
786
+ dryRun,
787
+ history: opts.history === true
788
+ });
789
+ const appliedDatabaseRows = results.stale_peers + results.expired_locks +
790
+ results.old_events + results.old_tasks + results.old_messages + results.old_handoffs;
791
+ if (!dryRun && appliedDatabaseRows > 0) {
792
+ // Reclaim WAL after deleting many rows (conc-05). Best-effort: may be busy
793
+ // with other connections; the result is reported but never blocks.
794
+ try {
795
+ const cp = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
796
+ if (cp) results.wal_checkpoint = cp;
797
+ } catch {}
798
+ }
799
+ } finally {
800
+ db.close();
801
+ }
802
+
803
+ printResult(ctx, results, (r) => {
804
+ const lines = [`gc completed${dryRun ? ' (dry-run, add --yes to apply)' : ''}:`];
805
+ if (r.buf_files) lines.push(` buffer files: ${r.buf_files}`);
806
+ if (r.protected_buf_files) lines.push(` protected buffer files: ${r.protected_buf_files}`);
807
+ if (r.deferred_buf_files) lines.push(` buffer files deferred: ${r.deferred_buf_files}`);
808
+ if (r.stale_peers) lines.push(` stale peers: ${r.stale_peers}`);
809
+ if (r.protected_stale_peers) lines.push(` protected stale peers: ${r.protected_stale_peers}`);
810
+ if (r.deferred_stale_peers) lines.push(` stale peers deferred: ${r.deferred_stale_peers}`);
811
+ if (r.deferred_unknown_peers) lines.push(` unknown peers deferred: ${r.deferred_unknown_peers}`);
812
+ if (r.deferred_history) lines.push(` history rows deferred: ${r.deferred_history}`);
813
+ if (r.deferred_age_based) lines.push(' age-based cleanup deferred by clock grace');
814
+ if (r.old_events) lines.push(` old events: ${r.old_events}`);
815
+ if (r.old_tasks) lines.push(` old tasks: ${r.old_tasks}`);
816
+ if (r.old_messages) lines.push(` old messages: ${r.old_messages}`);
817
+ if (r.old_handoffs) lines.push(` old handoffs: ${r.old_handoffs}`);
818
+ if (r.protected_old_events) lines.push(` protected old events: ${r.protected_old_events}`);
819
+ if (r.protected_old_tasks) lines.push(` protected old tasks: ${r.protected_old_tasks}`);
820
+ if (r.protected_old_messages) lines.push(` protected old messages: ${r.protected_old_messages}`);
821
+ if (r.protected_old_handoffs) lines.push(` protected old handoffs: ${r.protected_old_handoffs}`);
822
+ if (r.deferred_old_events) lines.push(` old events deferred: ${r.deferred_old_events}`);
823
+ if (r.deferred_old_tasks) lines.push(` old tasks deferred: ${r.deferred_old_tasks}`);
824
+ if (r.deferred_old_messages) lines.push(` old messages deferred: ${r.deferred_old_messages}`);
825
+ if (r.deferred_old_handoffs) lines.push(` old handoffs deferred: ${r.deferred_old_handoffs}`);
826
+ if (r.expired_locks) lines.push(` expired locks: ${r.expired_locks}`);
827
+ if (r.protected_expired_locks) lines.push(` protected expired locks: ${r.protected_expired_locks}`);
828
+ if (r.deferred_expired_locks) lines.push(` expired locks deferred by clock grace: ${r.deferred_expired_locks}`);
829
+ if (r.wal_checkpoint) lines.push(` wal checkpoint: ${r.wal_checkpoint.busy ? 'busy' : 'ok'} (log ${r.wal_checkpoint.log}, checkpointed ${r.wal_checkpoint.checkpointed})`);
830
+ if (!r.buf_files && !r.protected_buf_files && !r.deferred_buf_files && !r.stale_peers &&
831
+ !r.protected_stale_peers && !r.deferred_stale_peers && !r.old_events && !r.old_tasks &&
832
+ !r.old_messages && !r.old_handoffs && !r.expired_locks && !r.deferred_expired_locks &&
833
+ !r.protected_expired_locks && !r.deferred_unknown_peers && !r.deferred_history &&
834
+ !r.protected_old_events && !r.protected_old_tasks && !r.protected_old_messages &&
835
+ !r.protected_old_handoffs && !r.deferred_age_based) {
836
+ lines.push(' nothing to clean');
837
+ }
838
+ // v1-06: warn when history tables are retained so the user knows to add
839
+ // --history for events/tasks/messages/handoffs pruning.
840
+ if (!dryRun && opts.history !== true) {
841
+ lines.push(' (history tables retained; add --history to prune events/tasks/messages/handoffs)');
842
+ }
843
+ return lines.join('\n');
844
+ });
845
+ }
846
+
847
+ return {
848
+ cmdGc, runGc, runGcWithHistorySnapshot, bufferDirectory,
849
+ observeGcClockSafety, previewGcClockSafety, runManualGc,
850
+ prepareManualBufferGc, applyManualBufferGc, gcRetentionDays
851
+ };
852
+ }