@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,614 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ import { readTx, tx } from '../../db/schema.mjs';
4
+ import { aggregateCleanupFailure } from '../../shared/cleanup-error.mjs';
5
+
6
+ export const HISTORY_GC_BATCH_SIZE = 256;
7
+
8
+ const HISTORY_CATEGORIES = Object.freeze(['events', 'tasks', 'messages', 'handoffs']);
9
+ const PLAN_CHANGED = Symbol('gc-plan-changed');
10
+
11
+ const CURRENT_TMUX_AUTHORITIES = `
12
+ SELECT MAX(e.id)
13
+ FROM events e
14
+ JOIN peer_bindings b
15
+ ON b.peer = json_extract(e.payload, '$.target_peer')
16
+ WHERE e.type = 'tmux.session.attached'
17
+ AND b.transport = 'tmux'
18
+ AND (b.runtime_target IS NULL OR b.runtime_target = json_extract(e.payload, '$.pane'))
19
+ GROUP BY b.peer, b.runtime_target
20
+ `;
21
+
22
+ function rowById(db, table, column, value) {
23
+ return db.prepare(`SELECT * FROM ${table} WHERE ${column} = ?`).get(value) || null;
24
+ }
25
+
26
+ function deleteExactRow(db, table, row) {
27
+ const columns = Object.keys(row);
28
+ const predicate = columns.map((column) => `${column} IS ?`).join(' AND ');
29
+ return db.prepare(`DELETE FROM ${table} WHERE ${predicate}`)
30
+ .run(...columns.map((column) => row[column])).changes === 1;
31
+ }
32
+
33
+ function captureGcLockSubject(db, resource) {
34
+ const lock = rowById(db, 'locks', 'resource', resource);
35
+ if (!lock) return { lock: null, peer: null, binding: null, authority: null };
36
+ return {
37
+ lock,
38
+ peer: rowById(db, 'peers', 'id', lock.owner),
39
+ binding: rowById(db, 'peer_bindings', 'peer', lock.owner),
40
+ authority: lock.task_id === null || lock.task_id === undefined
41
+ ? null
42
+ : rowById(db, 'tasks', 'id', lock.task_id)
43
+ };
44
+ }
45
+
46
+ export function captureGcLockSubjects(db, observedAt) {
47
+ return db.prepare(`
48
+ SELECT resource FROM locks WHERE expires_at < ? ORDER BY resource ASC
49
+ `).all(observedAt).map(({ resource }) => captureGcLockSubject(db, resource));
50
+ }
51
+
52
+ export function finalizeGcLockSubjects(db, subjects, evidenceByOwner, options = {}) {
53
+ const dryRun = Boolean(options.dryRun);
54
+ return tx(db, () => {
55
+ const result = { deleted: 0, deferred: 0, live: 0 };
56
+ if (!dryRun && options.beforeMutate?.({ subjects }) === false) {
57
+ return { ...result, deferred: subjects.length, blocked: true };
58
+ }
59
+ for (const planned of subjects) {
60
+ const owner = planned.lock?.owner;
61
+ const current = planned.lock
62
+ ? captureGcLockSubject(db, planned.lock.resource)
63
+ : { lock: null, peer: null, binding: null, authority: null };
64
+ if (JSON.stringify(current) !== JSON.stringify(planned)) {
65
+ result.deferred += 1;
66
+ continue;
67
+ }
68
+ if (evidenceByOwner.get(owner)?.state === 'live') {
69
+ result.live += 1;
70
+ continue;
71
+ }
72
+ if (dryRun || deleteExactRow(db, 'locks', planned.lock)) result.deleted += 1;
73
+ else result.deferred += 1;
74
+ }
75
+ return result;
76
+ });
77
+ }
78
+
79
+ function normalizeHistoryCategories(categories = HISTORY_CATEGORIES) {
80
+ const requested = new Set(categories);
81
+ for (const category of requested) {
82
+ if (!HISTORY_CATEGORIES.includes(category)) {
83
+ throw new RangeError(`Unknown history GC category: ${category}`);
84
+ }
85
+ }
86
+ return HISTORY_CATEGORIES.filter((category) => requested.has(category));
87
+ }
88
+
89
+ function boundedBatchSize(value) {
90
+ if (value === undefined) return HISTORY_GC_BATCH_SIZE;
91
+ const size = Number(value);
92
+ if (!Number.isSafeInteger(size) || size <= 0) {
93
+ throw new RangeError('History GC batch size must be a positive safe integer');
94
+ }
95
+ return Math.min(size, HISTORY_GC_BATCH_SIZE);
96
+ }
97
+
98
+ function snapshotTableSql(snapshot) {
99
+ if (!snapshot || !/^history_gc_[a-f0-9]{24}$/.test(snapshot.tableName || '')) {
100
+ throw new RangeError('Invalid history GC snapshot');
101
+ }
102
+ return `temp."${snapshot.tableName}"`;
103
+ }
104
+
105
+ function insertSnapshotCategory(db, snapshot, category) {
106
+ const table = snapshotTableSql(snapshot);
107
+ if (category === 'events') {
108
+ db.prepare(`
109
+ INSERT INTO ${table}(category, id)
110
+ SELECT 'events', id FROM events
111
+ WHERE created_at < ? AND id NOT IN (${CURRENT_TMUX_AUTHORITIES})
112
+ `).run(snapshot.cutoff);
113
+ } else if (category === 'tasks') {
114
+ db.prepare(`
115
+ INSERT INTO ${table}(category, id)
116
+ SELECT 'tasks', id FROM tasks
117
+ WHERE status IN ('done', 'abandoned') AND updated_at < ?
118
+ `).run(snapshot.cutoff);
119
+ } else if (category === 'messages') {
120
+ db.prepare(`
121
+ INSERT INTO ${table}(category, id)
122
+ SELECT 'messages', id FROM messages WHERE created_at < ?
123
+ `).run(snapshot.cutoff);
124
+ } else if (category === 'handoffs') {
125
+ db.prepare(`
126
+ INSERT INTO ${table}(category, id)
127
+ SELECT 'handoffs', h.id
128
+ FROM handoffs h
129
+ LEFT JOIN tasks t ON t.id = h.task_id
130
+ WHERE h.created_at < ?
131
+ AND (h.task_id IS NULL OR t.id IS NULL OR t.status IN ('done', 'abandoned'))
132
+ `).run(snapshot.cutoff);
133
+ }
134
+ }
135
+
136
+ function snapshotTimestamp(db, snapshot, category) {
137
+ const table = snapshotTableSql(snapshot);
138
+ let row;
139
+ if (category === 'events') {
140
+ row = db.prepare(`
141
+ SELECT MAX(e.created_at) AS timestamp
142
+ FROM ${table} s JOIN events e ON e.id = s.id
143
+ WHERE s.category = 'events'
144
+ `).get();
145
+ } else if (category === 'tasks') {
146
+ row = db.prepare(`
147
+ SELECT MAX(t.updated_at) AS timestamp
148
+ FROM ${table} s JOIN tasks t ON t.id = s.id
149
+ WHERE s.category = 'tasks'
150
+ `).get();
151
+ } else if (category === 'messages') {
152
+ row = db.prepare(`
153
+ SELECT MAX(m.created_at) AS timestamp
154
+ FROM ${table} s JOIN messages m ON m.id = s.id
155
+ WHERE s.category = 'messages'
156
+ `).get();
157
+ } else if (category === 'handoffs') {
158
+ row = db.prepare(`
159
+ SELECT MAX(h.created_at) AS timestamp
160
+ FROM ${table} s JOIN handoffs h ON h.id = s.id
161
+ WHERE s.category = 'handoffs'
162
+ `).get();
163
+ }
164
+ return row?.timestamp === null || row?.timestamp === undefined
165
+ ? null
166
+ : Number(row.timestamp);
167
+ }
168
+
169
+ function snapshotProtectedCount(db, snapshot, category) {
170
+ if (category !== 'handoffs') return 0;
171
+ return Number(db.prepare(`
172
+ SELECT COUNT(*) AS count
173
+ FROM handoffs h
174
+ JOIN tasks t ON t.id = h.task_id
175
+ WHERE h.created_at < ?
176
+ AND t.status NOT IN ('done', 'abandoned')
177
+ `).get(snapshot.cutoff)?.count || 0);
178
+ }
179
+
180
+ export function createHistoryGcSnapshot(db, cutoff, options = {}) {
181
+ const categories = normalizeHistoryCategories(options.categories);
182
+ const batchSize = boundedBatchSize(options.batchSize);
183
+ const retentionSec = Number(options.retentionSec || 0);
184
+ const snapshot = {
185
+ tableName: `history_gc_${randomBytes(12).toString('hex')}`,
186
+ cutoff,
187
+ categories,
188
+ batchSize,
189
+ eligibleCounts: {},
190
+ protectedCounts: {},
191
+ maxTimestamps: [],
192
+ gcCutoffs: []
193
+ };
194
+ const table = snapshotTableSql(snapshot);
195
+ try {
196
+ readTx(db, () => {
197
+ db.exec(`
198
+ CREATE TEMP TABLE ${table} (
199
+ category TEXT NOT NULL,
200
+ id INTEGER NOT NULL,
201
+ PRIMARY KEY (category, id)
202
+ ) WITHOUT ROWID
203
+ `);
204
+ for (const category of categories) insertSnapshotCategory(db, snapshot, category);
205
+ const counts = new Map(db.prepare(`
206
+ SELECT category, COUNT(*) AS count FROM ${table} GROUP BY category
207
+ `).all().map((row) => [row.category, Number(row.count)]));
208
+ snapshot.eligibleCounts = Object.fromEntries(categories.map((category) => [
209
+ category,
210
+ counts.get(category) || 0
211
+ ]));
212
+ snapshot.protectedCounts = Object.fromEntries(categories.map((category) => [
213
+ category,
214
+ snapshotProtectedCount(db, snapshot, category)
215
+ ]));
216
+ snapshot.maxTimestamps = categories
217
+ .map((category) => snapshotTimestamp(db, snapshot, category))
218
+ .filter((value) => value !== null);
219
+ snapshot.gcCutoffs = snapshot.maxTimestamps.map((value) => value + retentionSec);
220
+ }, {
221
+ cleanupContext: `History GC snapshot read transaction for ${snapshot.tableName}`
222
+ });
223
+ return snapshot;
224
+ } catch (error) {
225
+ cleanupHistoryGcSnapshot(db, snapshot, {
226
+ primaryError: error,
227
+ context: 'History GC snapshot creation'
228
+ });
229
+ }
230
+ }
231
+
232
+ export function dropHistoryGcSnapshot(db, snapshot) {
233
+ db.exec(`DROP TABLE IF EXISTS ${snapshotTableSql(snapshot)}`);
234
+ }
235
+
236
+ function cleanupHistoryGcSnapshot(db, snapshot, options = {}) {
237
+ const hasPrimaryError = Object.hasOwn(options, 'primaryError');
238
+ try {
239
+ dropHistoryGcSnapshot(db, snapshot);
240
+ } catch (cleanupError) {
241
+ if (!hasPrimaryError) throw cleanupError;
242
+ const snapshotName = /^history_gc_[a-f0-9]{24}$/.test(snapshot?.tableName || '')
243
+ ? snapshot.tableName
244
+ : 'unknown snapshot';
245
+ throw aggregateCleanupFailure(
246
+ options.primaryError,
247
+ cleanupError,
248
+ `${options.context || 'History GC operation'} failed and cleanup of ${snapshotName} also failed`,
249
+ {
250
+ context: options.context || 'History GC operation',
251
+ snapshotName
252
+ }
253
+ );
254
+ }
255
+ if (hasPrimaryError) throw options.primaryError;
256
+ }
257
+
258
+ export function runWithHistoryGcSnapshotCleanup(
259
+ db,
260
+ snapshot,
261
+ operation,
262
+ context = 'History GC operation'
263
+ ) {
264
+ let result;
265
+ try {
266
+ result = operation(snapshot);
267
+ } catch (primaryError) {
268
+ cleanupHistoryGcSnapshot(db, snapshot, { primaryError, context });
269
+ }
270
+ cleanupHistoryGcSnapshot(db, snapshot);
271
+ return result;
272
+ }
273
+
274
+ export async function runWithHistoryGcSnapshotCleanupAsync(
275
+ db,
276
+ snapshot,
277
+ operation,
278
+ context = 'History GC operation'
279
+ ) {
280
+ let result;
281
+ try {
282
+ result = await operation(snapshot);
283
+ } catch (primaryError) {
284
+ cleanupHistoryGcSnapshot(db, snapshot, { primaryError, context });
285
+ }
286
+ cleanupHistoryGcSnapshot(db, snapshot);
287
+ return result;
288
+ }
289
+
290
+ function normalizedCounts(input, categories, label, maximums = null) {
291
+ return Object.fromEntries(categories.map((category) => {
292
+ const value = Number(input?.[category] || 0);
293
+ if (!Number.isSafeInteger(value) || value < 0 ||
294
+ (maximums && value > maximums[category])) {
295
+ throw new RangeError(`Invalid history GC ${label} for ${category}`);
296
+ }
297
+ return [category, value];
298
+ }));
299
+ }
300
+
301
+ function captureMessages(db, snapshot, afterId, limit) {
302
+ const table = snapshotTableSql(snapshot);
303
+ return db.prepare(`
304
+ SELECT m.*
305
+ FROM ${table} s
306
+ JOIN messages m ON m.id = s.id
307
+ WHERE s.category = 'messages' AND s.id > ? AND m.created_at < ?
308
+ ORDER BY s.id ASC
309
+ LIMIT ?
310
+ `)
311
+ .all(afterId, snapshot.cutoff, limit)
312
+ .map((message) => ({
313
+ message,
314
+ reads: db.prepare(`
315
+ SELECT * FROM message_reads WHERE message_id = ? ORDER BY peer ASC
316
+ `).all(message.id)
317
+ }));
318
+ }
319
+
320
+ function captureHandoffs(db, snapshot, afterId, limit) {
321
+ const table = snapshotTableSql(snapshot);
322
+ return db.prepare(`
323
+ SELECT h.id
324
+ FROM ${table} s
325
+ JOIN handoffs h ON h.id = s.id
326
+ LEFT JOIN tasks t ON t.id = h.task_id
327
+ WHERE s.category = 'handoffs'
328
+ AND s.id > ?
329
+ AND h.created_at < ?
330
+ AND (h.task_id IS NULL OR t.id IS NULL OR t.status IN ('done', 'abandoned'))
331
+ ORDER BY s.id ASC
332
+ LIMIT ?
333
+ `).all(afterId, snapshot.cutoff, limit).map(({ id }) => {
334
+ const handoff = rowById(db, 'handoffs', 'id', id);
335
+ return {
336
+ handoff,
337
+ task: handoff.task_id === null || handoff.task_id === undefined
338
+ ? null
339
+ : rowById(db, 'tasks', 'id', handoff.task_id)
340
+ };
341
+ });
342
+ }
343
+
344
+ export function captureHistoryGcPlan(db, cutoff, options = {}) {
345
+ const ownsSnapshot = !options.snapshot;
346
+ const snapshot = options.snapshot || createHistoryGcSnapshot(db, cutoff, options);
347
+ try {
348
+ return captureHistoryGcPlanFromSnapshot(db, cutoff, options, snapshot);
349
+ } catch (error) {
350
+ if (!ownsSnapshot) throw error;
351
+ cleanupHistoryGcSnapshot(db, snapshot, {
352
+ primaryError: error,
353
+ context: 'History GC plan construction'
354
+ });
355
+ }
356
+ }
357
+
358
+ function captureHistoryGcPlanFromSnapshot(db, cutoff, options, snapshot) {
359
+ const categories = snapshot.categories;
360
+ const batchSize = snapshot.batchSize;
361
+ if (Number(cutoff) !== Number(snapshot.cutoff)) {
362
+ throw new RangeError('History GC snapshot cutoff does not match the plan cutoff');
363
+ }
364
+ if (options.categories &&
365
+ JSON.stringify(normalizeHistoryCategories(options.categories)) !== JSON.stringify(categories)) {
366
+ throw new RangeError('History GC snapshot categories do not match the plan categories');
367
+ }
368
+ const after = Object.fromEntries(categories.map((category) => [
369
+ category,
370
+ Number(options.after?.[category] || 0)
371
+ ]));
372
+ const eligibleCounts = snapshot.eligibleCounts;
373
+ const processedCounts = normalizedCounts(
374
+ options.processedCounts,
375
+ categories,
376
+ 'processed count',
377
+ eligibleCounts
378
+ );
379
+ const remainingCounts = Object.fromEntries(categories.map((category) => [
380
+ category,
381
+ eligibleCounts[category] - processedCounts[category]
382
+ ]));
383
+ const plan = {
384
+ cutoff,
385
+ categories,
386
+ batchSize,
387
+ snapshot,
388
+ after,
389
+ eligibleCounts,
390
+ processedCounts,
391
+ remainingCounts,
392
+ nextAfter: { ...after },
393
+ events: [],
394
+ tasks: [],
395
+ messages: [],
396
+ handoffs: []
397
+ };
398
+ const activeCategories = categories.filter((category) => remainingCounts[category] > 0);
399
+ const baseAllocation = activeCategories.length > 0
400
+ ? Math.floor(batchSize / activeCategories.length)
401
+ : 0;
402
+ const extraAllocations = activeCategories.length > 0
403
+ ? batchSize % activeCategories.length
404
+ : 0;
405
+
406
+ for (const [index, category] of activeCategories.entries()) {
407
+ const allocation = Math.min(
408
+ remainingCounts[category],
409
+ baseAllocation + (index < extraAllocations ? 1 : 0)
410
+ );
411
+ if (allocation === 0) continue;
412
+ if (category === 'events') {
413
+ const table = snapshotTableSql(snapshot);
414
+ plan.events = db.prepare(`
415
+ SELECT e.*
416
+ FROM ${table} s
417
+ JOIN events e ON e.id = s.id
418
+ WHERE s.category = 'events' AND s.id > ? AND e.created_at < ?
419
+ AND e.id NOT IN (${CURRENT_TMUX_AUTHORITIES})
420
+ ORDER BY s.id ASC
421
+ LIMIT ?
422
+ `).all(after.events, cutoff, allocation);
423
+ } else if (category === 'tasks') {
424
+ const table = snapshotTableSql(snapshot);
425
+ plan.tasks = db.prepare(`
426
+ SELECT t.*
427
+ FROM ${table} s
428
+ JOIN tasks t ON t.id = s.id
429
+ WHERE s.category = 'tasks' AND s.id > ?
430
+ AND t.status IN ('done', 'abandoned') AND t.updated_at < ?
431
+ ORDER BY s.id ASC
432
+ LIMIT ?
433
+ `).all(after.tasks, cutoff, allocation);
434
+ } else if (category === 'messages') {
435
+ plan.messages = captureMessages(db, snapshot, after.messages, allocation);
436
+ } else if (category === 'handoffs') {
437
+ plan.handoffs = captureHandoffs(db, snapshot, after.handoffs, allocation);
438
+ }
439
+ const rows = category === 'messages'
440
+ ? plan.messages.map(({ message }) => message)
441
+ : category === 'handoffs'
442
+ ? plan.handoffs.map(({ handoff }) => handoff)
443
+ : plan[category];
444
+ if (rows.length > 0) plan.nextAfter[category] = Number(rows.at(-1).id);
445
+ }
446
+ return plan;
447
+ }
448
+
449
+ export function captureHistoryGcCutoffs(db, cutoff, retentionSec, options = {}) {
450
+ if (options.snapshot) {
451
+ return options.snapshot.maxTimestamps.map((value) => value + retentionSec);
452
+ }
453
+ const snapshot = createHistoryGcSnapshot(db, cutoff, { ...options, retentionSec });
454
+ return runWithHistoryGcSnapshotCleanup(
455
+ db,
456
+ snapshot,
457
+ () => [...snapshot.gcCutoffs],
458
+ 'History GC cutoff capture'
459
+ );
460
+ }
461
+
462
+ function selectedPlan(plan, categories) {
463
+ return Object.fromEntries(categories.map((category) => [category, plan[category]]));
464
+ }
465
+
466
+ function planCounts(plan, categories) {
467
+ return {
468
+ old_events: categories.includes('events') ? (plan.events?.length || 0) : 0,
469
+ old_tasks: categories.includes('tasks') ? (plan.tasks?.length || 0) : 0,
470
+ old_messages: categories.includes('messages') ? (plan.messages?.length || 0) : 0,
471
+ old_handoffs: categories.includes('handoffs') ? (plan.handoffs?.length || 0) : 0
472
+ };
473
+ }
474
+
475
+ export function finalizeHistoryGcPlan(db, plan, options = {}) {
476
+ const categories = normalizeHistoryCategories(options.categories || plan.categories);
477
+ const counts = planCounts(plan, categories);
478
+ const plannedTotal = Object.values(counts).reduce((sum, count) => sum + count, 0);
479
+ const deferredRemainder = plan.remainingCounts
480
+ ? categories.reduce((sum, category) => sum + Number(plan.remainingCounts[category]), 0)
481
+ : plannedTotal;
482
+ try {
483
+ return tx(db, () => {
484
+ if (!options.dryRun && options.beforeMutate?.({ plan, categories }) === false) {
485
+ return {
486
+ applied: false,
487
+ blocked: true,
488
+ ...planCounts({ events: [], tasks: [], messages: [], handoffs: [] }, categories),
489
+ deferred: deferredRemainder
490
+ };
491
+ }
492
+ const current = captureHistoryGcPlan(db, plan.cutoff, {
493
+ snapshot: plan.snapshot,
494
+ categories,
495
+ after: plan.after,
496
+ processedCounts: plan.processedCounts,
497
+ batchSize: plan.batchSize
498
+ });
499
+ if (JSON.stringify(selectedPlan(current, categories)) !==
500
+ JSON.stringify(selectedPlan(plan, categories))) {
501
+ return { applied: false, ...planCounts({ events: [], tasks: [], messages: [], handoffs: [] }, categories), deferred: deferredRemainder };
502
+ }
503
+ if (!options.dryRun) {
504
+ for (const event of categories.includes('events') ? plan.events : []) {
505
+ if (!deleteExactRow(db, 'events', event)) throw PLAN_CHANGED;
506
+ }
507
+ for (const task of categories.includes('tasks') ? plan.tasks : []) {
508
+ if (!deleteExactRow(db, 'tasks', task)) throw PLAN_CHANGED;
509
+ }
510
+ for (const { message } of categories.includes('messages') ? plan.messages : []) {
511
+ if (!deleteExactRow(db, 'messages', message)) throw PLAN_CHANGED;
512
+ }
513
+ for (const { handoff } of categories.includes('handoffs') ? plan.handoffs : []) {
514
+ if (!deleteExactRow(db, 'handoffs', handoff)) throw PLAN_CHANGED;
515
+ }
516
+ }
517
+ return { applied: true, ...counts, deferred: 0 };
518
+ });
519
+ } catch (error) {
520
+ if (error !== PLAN_CHANGED) throw error;
521
+ return {
522
+ applied: false,
523
+ old_events: 0,
524
+ old_tasks: 0,
525
+ old_messages: 0,
526
+ old_handoffs: 0,
527
+ deferred: deferredRemainder
528
+ };
529
+ }
530
+ }
531
+
532
+ function historyPlanCount(plan) {
533
+ return plan.events.length + plan.tasks.length + plan.messages.length + plan.handoffs.length;
534
+ }
535
+
536
+ function processedCountsFromResult(result) {
537
+ return {
538
+ events: result.old_events,
539
+ tasks: result.old_tasks,
540
+ messages: result.old_messages,
541
+ handoffs: result.old_handoffs
542
+ };
543
+ }
544
+
545
+ export function finalizeHistoryGcBatches(db, initialPlan, options = {}) {
546
+ const alreadyProcessed = initialPlan.processedCounts || {};
547
+ const totals = {
548
+ old_events: Number(alreadyProcessed.events || 0),
549
+ old_tasks: Number(alreadyProcessed.tasks || 0),
550
+ old_messages: Number(alreadyProcessed.messages || 0),
551
+ old_handoffs: Number(alreadyProcessed.handoffs || 0),
552
+ deferred: 0
553
+ };
554
+ const eligibleCounts = initialPlan.eligibleCounts;
555
+ const processBatches = () => {
556
+ let plan = initialPlan;
557
+ while (historyPlanCount(plan) > 0) {
558
+ const result = finalizeHistoryGcPlan(db, plan, {
559
+ dryRun: options.dryRun,
560
+ beforeMutate: options.beforeMutate
561
+ });
562
+ totals.old_events += result.old_events;
563
+ totals.old_tasks += result.old_tasks;
564
+ totals.old_messages += result.old_messages;
565
+ totals.old_handoffs += result.old_handoffs;
566
+ totals.deferred += result.deferred;
567
+ if (!result.applied) break;
568
+ options.afterBatch?.({ plan, result, totals: { ...totals } });
569
+
570
+ plan = captureHistoryGcPlan(db, plan.cutoff, {
571
+ snapshot: plan.snapshot,
572
+ categories: plan.categories,
573
+ after: plan.nextAfter,
574
+ processedCounts: processedCountsFromResult(totals),
575
+ batchSize: plan.batchSize
576
+ });
577
+ }
578
+ const processedCounts = processedCountsFromResult(totals);
579
+ const snapshotRemainder = initialPlan.categories.reduce(
580
+ (sum, category) => sum + eligibleCounts[category] - processedCounts[category],
581
+ 0
582
+ );
583
+ totals.deferred = Math.max(totals.deferred, snapshotRemainder);
584
+ return totals;
585
+ };
586
+ if (options.dropSnapshot === false) return processBatches();
587
+ return runWithHistoryGcSnapshotCleanup(
588
+ db,
589
+ initialPlan.snapshot,
590
+ processBatches,
591
+ 'History GC batch processing'
592
+ );
593
+ }
594
+
595
+ export function historyGcCutoffs(plan, retentionSec) {
596
+ const cutoffs = [];
597
+ const categories = normalizeHistoryCategories(plan.categories);
598
+ for (const category of categories) {
599
+ const entries = plan[category] || [];
600
+ const timestamp = category === 'tasks' ? 'updated_at' : 'created_at';
601
+ let maximum = null;
602
+ for (const entry of entries) {
603
+ const row = category === 'messages'
604
+ ? entry.message
605
+ : category === 'handoffs'
606
+ ? entry.handoff
607
+ : entry;
608
+ const value = Number(row[timestamp]);
609
+ if (maximum === null || value > maximum) maximum = value;
610
+ }
611
+ if (maximum !== null) cutoffs.push(maximum + retentionSec);
612
+ }
613
+ return cutoffs;
614
+ }