@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,838 @@
1
+ // Scan + tmux commands extracted from bin/hcc.mjs.
2
+ // hcc scan detects provider sessions; the tmux helpers implement DB-proven,
3
+ // evidence-checked tmux GC (plan/validate/execute) for hcc-managed sessions.
4
+
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { CliError } from '../../shared/errors.mjs';
9
+ import { tx } from '../../db/schema.mjs';
10
+ import { intOpt, parseOpts, validateOpts, wantsHelp } from '../../cli-args.mjs';
11
+ import { printResult, table } from '../../format.mjs';
12
+ import { readRuntime } from '../../runtime/state.mjs';
13
+ import { runtimeRequest } from '../../runtime/client.mjs';
14
+ import { registerProjectActivity } from '../../runtime/projects.mjs';
15
+ import { detectBranch } from '../../project-context.mjs';
16
+ import { bindingFromDetected } from '../../core/peers/bindings.mjs';
17
+ import { resolvePeerEvidence } from '../../core/peers/evidence.mjs';
18
+ import {
19
+ conditionalTmuxKill,
20
+ finalizeTmuxGcBindingMutation,
21
+ validateTmuxDestructiveEvidence,
22
+ validateTmuxGcBindingEvidence,
23
+ validateTmuxGcDeadProcessEvidence
24
+ } from '../../core/peers/tmux-safety.mjs';
25
+ import { inspectProcessIdentity } from '../../process/identity.mjs';
26
+ import {
27
+ ensureTmuxAvailable,
28
+ runTmux,
29
+ tmuxManagedSessionName,
30
+ tmuxPaneInfo,
31
+ tmuxSessionEnvironmentValue
32
+ } from '../../tmux.mjs';
33
+ import { resolveCurrentPeer } from '../../integrations/peers/identity.mjs';
34
+
35
+ export function createTmuxCommands(deps) {
36
+ const {
37
+ connect, now, addEvent, auditPayload,
38
+ upsertPeer, upsertCanonicalPeerBinding,
39
+ loadDiscover, sameResolvedPath,
40
+ helpTmux,
41
+ canonicalRoot, rootEvidence, processEvidenceFromRow,
42
+ inspectTmuxTarget, isProjectManagedTmuxSession
43
+ } = deps;
44
+
45
+ async function cmdScan(ctx, args) {
46
+ const opts = parseOpts(args, { booleans: ['register'] });
47
+ const { scanClaudeSessions, scanCodexSessions, scanProcesses } = await loadDiscover();
48
+
49
+ const found = [
50
+ ...scanClaudeSessions(),
51
+ ...scanCodexSessions(),
52
+ ...scanProcesses(),
53
+ ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
54
+
55
+ // Deduplicate by peerId
56
+ const byId = new Map();
57
+ for (const s of found) {
58
+ if (!byId.has(s.peerId)) byId.set(s.peerId, s);
59
+ }
60
+ const results = [...byId.values()];
61
+
62
+ if (opts.register && results.length) {
63
+ registerProjectActivity(ctx);
64
+ const db = connect(ctx);
65
+ try {
66
+ for (const s of results) {
67
+ upsertPeer(db, {
68
+ id: s.peerId, kind: s.kind, role: 'peer',
69
+ worktree: s.cwd,
70
+ branch: detectBranch(s.cwd),
71
+ pid: s.pid,
72
+ status: s.status || 'running',
73
+ capabilities: 'detected'
74
+ });
75
+ upsertCanonicalPeerBinding(db, bindingFromDetected(s, s.transport || 'detected'), true);
76
+ }
77
+ } finally {
78
+ db.close();
79
+ }
80
+ }
81
+
82
+ printResult(ctx, results, (rows) => {
83
+ if (!rows.length) return 'no active sessions found in this project';
84
+ return table(rows, [
85
+ { label: 'peer', value: (r) => r.peerId },
86
+ { label: 'kind', value: (r) => r.kind },
87
+ { label: 'pid', value: (r) => r.pid || '' },
88
+ { label: 'cwd', value: (r) => r.cwd },
89
+ { label: 'session', value: (r) => (r.sessionId || '').slice(0, 16) },
90
+ { label: 'transport', value: (r) => r.transport },
91
+ ]);
92
+ });
93
+ }
94
+
95
+ // ─── hcc tmux ────────────────────────────────────────────────────────────────
96
+
97
+ function tmuxSessionNameForTarget(target) {
98
+ if (!target) return null;
99
+ try {
100
+ return runTmux(['display-message', '-p', '-t', target, '#{session_name}']).trim() || null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function tmuxSessionCreationToken(target) {
107
+ if (!target) return null;
108
+ try {
109
+ return runTmux(['display-message', '-p', '-t', target, '#{session_created}']).trim() || null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ function tmuxSessionId(target) {
116
+ if (!target) return null;
117
+ try {
118
+ return runTmux(['display-message', '-p', '-t', target, '#{session_id}']).trim() || null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function tmuxPaneForTarget(target) {
125
+ if (!target) return null;
126
+ try {
127
+ return runTmux(['display-message', '-p', '-t', target, '#{pane_id}']).trim() || null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ async function managedRuntimeSessions(ctx) {
134
+ try {
135
+ const runtime = readRuntime(ctx);
136
+ const data = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
137
+ return data.sessions || [];
138
+ } catch {
139
+ return [];
140
+ }
141
+ }
142
+
143
+ function parseJsonObject(value) {
144
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
145
+ if (typeof value !== 'string' || !value) return null;
146
+ try {
147
+ const parsed = JSON.parse(value);
148
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function strictTmuxClientObservation(sessionName) {
155
+ if (!sessionName) return { state: 'unknown', count: null };
156
+ try {
157
+ const output = runTmux(['list-clients', '-t', sessionName, '-F', '#{client_tty}']);
158
+ return { state: 'known', count: output.trim().split('\n').filter(Boolean).length };
159
+ } catch (error) {
160
+ return { state: 'unknown', count: null, error: error?.message || String(error) };
161
+ }
162
+ }
163
+
164
+ function tmuxAttachmentAuthority(db, peerId, runtimeTarget) {
165
+ const row = db.prepare(`
166
+ SELECT payload
167
+ FROM events
168
+ WHERE type = 'tmux.session.attached'
169
+ AND json_extract(payload, '$.target_peer') = ?
170
+ AND json_extract(payload, '$.pane') = ?
171
+ ORDER BY id DESC
172
+ LIMIT 1
173
+ `).get(peerId, runtimeTarget);
174
+ const payload = parseJsonObject(row?.payload) || {};
175
+ return {
176
+ session: payload.tmux_session || null,
177
+ session_created: payload.tmux_session_created || null,
178
+ session_id: payload.tmux_session_id || null,
179
+ root: payload.hcc_root || null,
180
+ pane: payload.pane || null,
181
+ process_identity: payload.process_identity || null
182
+ };
183
+ }
184
+
185
+ function tmuxGcBindingSubject(ctx, row) {
186
+ const authority = row.authority || {};
187
+ return {
188
+ peer: row.peer || row.id || null,
189
+ status: row.status || null,
190
+ pid: row.pid || null,
191
+ pid_start_token: row.pid_start_token || null,
192
+ pid_command_hash: row.pid_command_hash || null,
193
+ last_seen_at: row.last_seen_at || null,
194
+ transport: row.transport || null,
195
+ runtime_target: row.runtime_target || null,
196
+ runtime_session_id: row.runtime_session_id || null,
197
+ updated_at: row.updated_at || null,
198
+ expected_session: tmuxManagedSessionName(ctx, row.peer || row.id),
199
+ expected_root: ctx.root || null,
200
+ authority: {
201
+ session: authority.session || null,
202
+ session_created: authority.session_created || null,
203
+ session_id: authority.session_id || null,
204
+ root: authority.root || null,
205
+ pane: authority.pane || null,
206
+ process_identity: authority.process_identity || null
207
+ }
208
+ };
209
+ }
210
+
211
+ function tmuxGcBindingSubjectFromDb(db, ctx, peerId) {
212
+ const row = db.prepare(`
213
+ SELECT p.id AS peer, p.status, p.pid, p.pid_start_token,
214
+ p.pid_command_hash, p.last_seen_at,
215
+ b.transport, b.runtime_target, b.runtime_session_id, b.updated_at
216
+ FROM peers p
217
+ JOIN peer_bindings b ON b.peer = p.id
218
+ WHERE p.id = ?
219
+ `).get(peerId);
220
+ if (!row) return null;
221
+ return tmuxGcBindingSubject(ctx, {
222
+ ...row,
223
+ authority: tmuxAttachmentAuthority(db, peerId, row.runtime_target)
224
+ });
225
+ }
226
+
227
+ function sameTmuxGcBindingSubject(planned, current) {
228
+ return Boolean(planned && current) && JSON.stringify(planned) === JSON.stringify(current);
229
+ }
230
+
231
+ const TMUX_GC_CONDITIONAL_TIMEOUT_MS = 5000;
232
+
233
+ function runBoundedTmuxGcCommand(args) {
234
+ const result = spawnSync('tmux', args, {
235
+ encoding: 'utf8',
236
+ stdio: ['pipe', 'pipe', 'pipe'],
237
+ input: '',
238
+ timeout: TMUX_GC_CONDITIONAL_TIMEOUT_MS,
239
+ killSignal: 'SIGKILL'
240
+ });
241
+ if (result.error) {
242
+ const timedOut = result.error.code === 'ETIMEDOUT';
243
+ throw new CliError(
244
+ timedOut ? 'TMUX_CONDITIONAL_KILL_TIMEOUT' : 'TMUX_ERROR',
245
+ timedOut
246
+ ? `tmux conditional kill exceeded ${TMUX_GC_CONDITIONAL_TIMEOUT_MS}ms`
247
+ : `tmux failed: ${result.error.message}`
248
+ );
249
+ }
250
+ if (result.status !== 0) {
251
+ const message = (result.stderr || result.stdout || '').trim() || `tmux ${args.join(' ')} failed`;
252
+ throw new CliError('TMUX_ERROR', message);
253
+ }
254
+ return result.stdout || '';
255
+ }
256
+
257
+ function casDetachTmuxGcBinding(db, subject, timestamp) {
258
+ return db.prepare(`
259
+ UPDATE peer_bindings
260
+ SET transport = 'detached',
261
+ runtime_target = NULL,
262
+ updated_at = ?
263
+ WHERE peer = ?
264
+ AND transport = 'tmux'
265
+ AND runtime_target = ?
266
+ AND runtime_session_id IS ?
267
+ AND updated_at IS ?
268
+ `).run(
269
+ timestamp,
270
+ subject.peer,
271
+ subject.runtime_target,
272
+ subject.runtime_session_id,
273
+ subject.updated_at
274
+ );
275
+ }
276
+
277
+ function casDetachTmuxGcPeer(db, subject, timestamp) {
278
+ const result = db.prepare(`
279
+ UPDATE peers
280
+ SET status = 'detached',
281
+ last_seen_at = ?
282
+ WHERE id = ?
283
+ AND status IS ?
284
+ AND pid IS ?
285
+ AND pid_start_token IS ?
286
+ AND pid_command_hash IS ?
287
+ AND last_seen_at IS ?
288
+ `).run(
289
+ timestamp,
290
+ subject.peer,
291
+ subject.status,
292
+ subject.pid,
293
+ subject.pid_start_token,
294
+ subject.pid_command_hash,
295
+ subject.last_seen_at
296
+ );
297
+ if (Number(result.changes) !== 1) {
298
+ throw new CliError('TMUX_GC_PEER_CAS_FAILED',
299
+ 'tmux GC peer compare-and-swap did not update exactly one row');
300
+ }
301
+ return result;
302
+ }
303
+
304
+ function observeTmuxConditionalTarget(runtimeTarget) {
305
+ let paneInfo = null;
306
+ try { paneInfo = tmuxPaneInfo(runtimeTarget); } catch {}
307
+ const pane = paneInfo?.pane || tmuxPaneForTarget(runtimeTarget);
308
+ const session = tmuxSessionNameForTarget(runtimeTarget);
309
+ const processInspection = inspectProcessIdentity(paneInfo?.pid);
310
+ return {
311
+ session,
312
+ session_created: tmuxSessionCreationToken(session),
313
+ session_id: tmuxSessionId(session),
314
+ root: canonicalRoot(tmuxSessionEnvironmentValue(session, 'HCC_ROOT')),
315
+ pane,
316
+ pane_pid: paneInfo?.pid || null,
317
+ pane_dead: paneInfo?.dead === true,
318
+ process_identity: processInspection.state === 'live' ? processInspection.identity : null,
319
+ process_inspection: processInspection,
320
+ clients: strictTmuxClientObservation(session)
321
+ };
322
+ }
323
+
324
+ function rebindCandidateAuthority(candidate) {
325
+ return {
326
+ session: candidate.expected_session || candidate.session || null,
327
+ session_created: candidate.old_tmux_session_created || null,
328
+ session_id: candidate.old_tmux_session_id || null,
329
+ root: canonicalRoot(candidate.old_hcc_root),
330
+ pane: candidate.old_pane || null,
331
+ process_identity: parseJsonObject(candidate.old_process_identity)
332
+ };
333
+ }
334
+
335
+ function validateTmuxGcDeadRebindEvidence(candidate) {
336
+ const stored = rebindCandidateAuthority(candidate);
337
+ const observed = observeTmuxConditionalTarget(candidate.runtime_target || candidate.session || '');
338
+ return {
339
+ ...validateTmuxGcDeadProcessEvidence(stored, observed),
340
+ stored,
341
+ observed
342
+ };
343
+ }
344
+
345
+ function validateTmuxGcBindingCandidate(subject) {
346
+ const observed = observeTmuxConditionalTarget(subject?.runtime_target || '');
347
+ const ownerEvidence = subject
348
+ ? resolvePeerEvidence({
349
+ peer: subject,
350
+ processes: [processEvidenceFromRow(subject, 'owner')]
351
+ })
352
+ : { state: 'unknown', reason: 'peer_missing' };
353
+ const validationSubject = subject ? {
354
+ ...subject,
355
+ owner_evidence: ownerEvidence,
356
+ expected_root: canonicalRoot(subject.expected_root),
357
+ authority: subject.authority ? {
358
+ ...subject.authority,
359
+ root: canonicalRoot(subject.authority.root)
360
+ } : null
361
+ } : subject;
362
+ return {
363
+ ...validateTmuxGcBindingEvidence(validationSubject, observed),
364
+ stored: subject?.authority || null,
365
+ observed,
366
+ subject
367
+ };
368
+ }
369
+
370
+ function observeRebindEventEvidence(ctx, candidate) {
371
+ const sessionName = candidate.expected_session || candidate.session || null;
372
+ const storedIdentity = parseJsonObject(candidate.old_process_identity);
373
+ const storedRoot = canonicalRoot(candidate.old_hcc_root);
374
+ const storedSessionCreated = candidate.old_tmux_session_created
375
+ ? String(candidate.old_tmux_session_created)
376
+ : null;
377
+ const storedSessionId = candidate.old_tmux_session_id || null;
378
+ const storedPane = candidate.old_pane || null;
379
+ if (!sessionName || !candidate.runtime_target || !storedIdentity || !storedRoot ||
380
+ !storedSessionCreated || !storedSessionId || !storedPane) {
381
+ return { state: 'unknown', reason: 'tmux_event_evidence_incomplete' };
382
+ }
383
+
384
+ const target = inspectTmuxTarget(sessionName, candidate.runtime_target);
385
+ const currentSessionCreated = target.session.actual
386
+ ? tmuxSessionCreationToken(target.session.actual)
387
+ : null;
388
+ const currentSessionId = target.session.actual
389
+ ? tmuxSessionId(target.session.actual)
390
+ : null;
391
+ const exactSession = target.session.state === 'live' &&
392
+ target.session.actual === sessionName &&
393
+ currentSessionCreated === storedSessionCreated &&
394
+ currentSessionId === storedSessionId;
395
+ const session = target.session.state === 'dead'
396
+ ? { state: 'dead', expected: `${sessionName}:${storedSessionCreated}`, actual: null }
397
+ : {
398
+ state: exactSession ? 'live' : 'unknown',
399
+ expected: `${sessionName}:${storedSessionCreated}`,
400
+ actual: target.session.actual && currentSessionCreated
401
+ ? `${target.session.actual}:${currentSessionCreated}`
402
+ : null
403
+ };
404
+ const pane = target.pane.actual && target.pane.actual !== storedPane
405
+ ? { state: 'unknown', expected: storedPane, actual: target.pane.actual }
406
+ : { ...target.pane, expected: storedPane };
407
+ const actualRoot = target.session.actual
408
+ ? canonicalRoot(tmuxSessionEnvironmentValue(target.session.actual, 'HCC_ROOT'))
409
+ : null;
410
+ const expectedRoot = canonicalRoot(ctx.root);
411
+ const root = !expectedRoot || !actualRoot
412
+ ? { state: 'unknown', expected: expectedRoot, actual: actualRoot }
413
+ : {
414
+ state: storedRoot === expectedRoot && actualRoot === storedRoot ? 'match' : 'mismatch',
415
+ expected: storedRoot,
416
+ actual: actualRoot
417
+ };
418
+ return resolvePeerEvidence({
419
+ peer: { status: 'working' },
420
+ tmux: {
421
+ managed: true,
422
+ session,
423
+ pane,
424
+ root,
425
+ process: {
426
+ name: 'old-pane',
427
+ storedIdentity,
428
+ current: inspectProcessIdentity(target.paneInfo?.pid || storedIdentity.pid)
429
+ }
430
+ }
431
+ });
432
+ }
433
+
434
+ async function planTmuxGc(ctx, opts) {
435
+ ensureTmuxAvailable({ autoInstall: false });
436
+ const olderThanDays = intOpt(opts, 'older-than', 14);
437
+ if (olderThanDays < 0) throw new CliError('BAD_ARGS', '--older-than must be zero or greater');
438
+ const targetPeer = opts.peer || null;
439
+ const cutoff = now() - olderThanDays * 86400;
440
+ const runtimeSessions = await managedRuntimeSessions(ctx);
441
+ const managedPanes = new Set(runtimeSessions.map((s) => s.pane).filter(Boolean));
442
+ const managedPeers = new Set();
443
+ for (const session of runtimeSessions) {
444
+ if (session.id) managedPeers.add(session.id);
445
+ if (session.peer_id) managedPeers.add(session.peer_id);
446
+ }
447
+
448
+ const db = connect(ctx);
449
+ let rows = [];
450
+ let cleanupFailureRows = [];
451
+ try {
452
+ rows = db.prepare(`
453
+ SELECT p.id AS peer, p.kind, p.status, p.pid,
454
+ p.pid_start_token, p.pid_command_hash, p.last_seen_at,
455
+ b.provider, b.provider_session_id, b.provider_session_name,
456
+ b.resume_mode, b.resume_arg, b.command, b.transport,
457
+ b.runtime_session_id, b.runtime_target, b.updated_at
458
+ FROM peer_bindings b
459
+ JOIN peers p ON p.id = b.peer
460
+ WHERE b.transport = 'tmux'
461
+ AND b.runtime_target IS NOT NULL
462
+ ORDER BY b.updated_at ASC, p.last_seen_at ASC, p.id ASC
463
+ `).all();
464
+ rows = rows.map((row) => ({
465
+ ...row,
466
+ authority: tmuxAttachmentAuthority(db, row.peer, row.runtime_target)
467
+ }));
468
+ cleanupFailureRows = db.prepare(`
469
+ SELECT actor AS peer, type, created_at,
470
+ json_extract(payload, '$.old_peer') AS old_peer,
471
+ json_extract(payload, '$.old_tmux_session') AS old_tmux_session,
472
+ json_extract(payload, '$.old_runtime_target') AS old_runtime_target,
473
+ json_extract(payload, '$.new_runtime_target') AS new_runtime_target,
474
+ json_extract(payload, '$.old_pane') AS old_pane,
475
+ json_extract(payload, '$.old_process_identity') AS old_process_identity,
476
+ json_extract(payload, '$.old_hcc_root') AS old_hcc_root,
477
+ json_extract(payload, '$.old_tmux_session_created') AS old_tmux_session_created,
478
+ json_extract(payload, '$.old_tmux_session_id') AS old_tmux_session_id,
479
+ json_extract(payload, '$.reason') AS cleanup_reason
480
+ FROM events
481
+ WHERE type IN ('tmux.session.rebind_cleanup_failed', 'tmux.session.rebind_cleanup_pending')
482
+ AND created_at < ?
483
+ ORDER BY created_at ASC, id ASC
484
+ `).all(cutoff);
485
+ } finally {
486
+ db.close();
487
+ }
488
+
489
+ const seenSessions = new Set();
490
+ const candidates = [];
491
+ const skipped = [];
492
+ for (const row of rows) {
493
+ if (targetPeer && row.peer !== targetPeer) continue;
494
+ const bindingSubject = tmuxGcBindingSubject(ctx, row);
495
+ const strictBinding = validateTmuxGcBindingCandidate(bindingSubject);
496
+ const expectedSession = tmuxManagedSessionName(ctx, row.peer);
497
+ const actualSession = tmuxSessionNameForTarget(row.runtime_target);
498
+ const actualPane = tmuxPaneForTarget(row.runtime_target);
499
+ const ageSeconds = Math.max(0, now() - Math.max(Number(row.last_seen_at || 0), Number(row.updated_at || 0)));
500
+ const base = {
501
+ peer: row.peer,
502
+ kind: row.kind || '',
503
+ provider: row.provider,
504
+ session: actualSession || expectedSession,
505
+ expected_session: expectedSession,
506
+ pane: actualPane || row.runtime_target,
507
+ runtime_target: row.runtime_target,
508
+ runtime_session_id: row.runtime_session_id || null,
509
+ last_seen_at: row.last_seen_at || null,
510
+ updated_at: row.updated_at || null,
511
+ age_days: Math.floor(ageSeconds / 86400),
512
+ authority: row.authority
513
+ };
514
+ const skip = (reason, extra = {}) => skipped.push({ ...base, reason, ...extra });
515
+
516
+ if (!strictBinding.ok) {
517
+ skip(strictBinding.reason, { evidence_state: 'unknown' });
518
+ continue;
519
+ }
520
+
521
+ if (!actualSession || !actualPane) {
522
+ skip('tmux_target_missing');
523
+ continue;
524
+ }
525
+ if (actualSession !== expectedSession) {
526
+ skip('not_hcc_managed_name');
527
+ continue;
528
+ }
529
+ const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');
530
+ if (hccRoot && path.resolve(hccRoot) !== path.resolve(ctx.root)) {
531
+ skip('hcc_root_mismatch', { hcc_root: hccRoot });
532
+ continue;
533
+ }
534
+ if (seenSessions.has(actualSession)) {
535
+ skip('duplicate_db_binding');
536
+ continue;
537
+ }
538
+ seenSessions.add(actualSession);
539
+ if (managedPanes.has(actualPane) || managedPanes.has(row.runtime_target) || managedPeers.has(row.peer) || managedPeers.has(row.runtime_session_id)) {
540
+ skip('runtime_managed');
541
+ continue;
542
+ }
543
+ const clientCount = strictBinding.observed.clients.count;
544
+ if (Math.max(Number(row.last_seen_at || 0), Number(row.updated_at || 0)) >= cutoff) {
545
+ skip('not_old_enough');
546
+ continue;
547
+ }
548
+ candidates.push({
549
+ ...base,
550
+ source: 'binding',
551
+ reason: 'stale_hcc_managed_session',
552
+ hcc_root: hccRoot || null,
553
+ client_count: clientCount,
554
+ evidence_state: 'dead',
555
+ evidence_reason: strictBinding.mode === 'dead_process'
556
+ ? 'tmux_process_confirmed_dead'
557
+ : 'explicit_exited',
558
+ gc_validation_mode: strictBinding.mode,
559
+ gc_validation_subject: bindingSubject
560
+ });
561
+ }
562
+ for (const row of cleanupFailureRows) {
563
+ const rowPeer = row.old_peer || row.peer || '';
564
+ if (targetPeer && rowPeer !== targetPeer) continue;
565
+ const expectedSession = row.old_tmux_session || null;
566
+ const actualSession = tmuxSessionNameForTarget(row.old_runtime_target);
567
+ const actualPane = tmuxPaneForTarget(row.old_runtime_target);
568
+ const ageSeconds = Math.max(0, now() - Number(row.created_at || 0));
569
+ const base = {
570
+ peer: rowPeer,
571
+ kind: '',
572
+ provider: '',
573
+ session: actualSession || expectedSession || '',
574
+ expected_session: expectedSession || '',
575
+ pane: actualPane || row.old_runtime_target || '',
576
+ runtime_target: row.old_runtime_target || null,
577
+ last_seen_at: null,
578
+ updated_at: row.created_at || null,
579
+ age_days: Math.floor(ageSeconds / 86400),
580
+ cleanup_reason: row.cleanup_reason || null,
581
+ old_pane: row.old_pane || null,
582
+ old_process_identity: row.old_process_identity || null,
583
+ old_hcc_root: row.old_hcc_root || null,
584
+ old_tmux_session_created: row.old_tmux_session_created || null,
585
+ old_tmux_session_id: row.old_tmux_session_id || null
586
+ };
587
+ const skip = (reason, extra = {}) => skipped.push({ ...base, reason, ...extra });
588
+
589
+ const strictDead = validateTmuxGcDeadRebindEvidence(base);
590
+ if (!strictDead.ok) {
591
+ skip(strictDead.reason, { evidence_state: 'unknown' });
592
+ continue;
593
+ }
594
+
595
+ if (!expectedSession || !actualSession || !actualPane) {
596
+ skip('tmux_target_missing');
597
+ continue;
598
+ }
599
+ if (actualSession !== expectedSession) {
600
+ skip('old_runtime_target_changed', { actual_session: actualSession });
601
+ continue;
602
+ }
603
+ const hccRoot = tmuxSessionEnvironmentValue(expectedSession, 'HCC_ROOT');
604
+ if (!isProjectManagedTmuxSession(ctx, expectedSession, hccRoot)) {
605
+ skip('not_hcc_managed_name');
606
+ continue;
607
+ }
608
+ if (rootEvidence(ctx.root, hccRoot).state !== 'match') {
609
+ skip('hcc_root_mismatch', { hcc_root: hccRoot });
610
+ continue;
611
+ }
612
+ if (seenSessions.has(expectedSession)) {
613
+ skip('duplicate_db_binding');
614
+ continue;
615
+ }
616
+ seenSessions.add(expectedSession);
617
+ if (managedPanes.has(actualPane) || managedPanes.has(row.old_runtime_target)) {
618
+ skip('runtime_managed');
619
+ continue;
620
+ }
621
+ const clientCount = strictDead.observed.clients.count;
622
+ candidates.push({
623
+ ...base,
624
+ source: row.type === 'tmux.session.rebind_cleanup_pending' ? 'rebind_cleanup_pending' : 'rebind_cleanup_failed',
625
+ reason: row.type === 'tmux.session.rebind_cleanup_pending'
626
+ ? 'stale_rebind_cleanup_pending_session'
627
+ : 'stale_rebind_cleanup_failed_session',
628
+ session: expectedSession,
629
+ hcc_root: hccRoot || null,
630
+ evidence_state: 'dead',
631
+ evidence_reason: 'tmux_process_confirmed_dead',
632
+ client_count: clientCount
633
+ });
634
+ }
635
+ return { older_than_days: olderThanDays, cutoff, peer: targetPeer, candidates, skipped };
636
+ }
637
+
638
+ function validateTmuxGcCandidate(ctx, candidate, runtimeSessions = [], options = {}) {
639
+ const target = candidate.runtime_target || candidate.session || '';
640
+ const actualSession = tmuxSessionNameForTarget(target);
641
+ const actualPane = tmuxPaneForTarget(target);
642
+ const skip = (reason, extra = {}) => ({ ok: false, reason, ...extra });
643
+ if (!candidate.session || !actualSession || !actualPane) return skip('tmux_target_missing');
644
+ if (actualSession !== candidate.session) {
645
+ return skip('tmux_target_changed', { session: actualSession, pane: actualPane });
646
+ }
647
+ if (candidate.source !== 'binding' && candidate.old_pane !== actualPane) {
648
+ return skip('tmux_pane_changed', { pane: actualPane });
649
+ }
650
+ const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');
651
+ if (!isProjectManagedTmuxSession(ctx, actualSession, hccRoot)) return skip('not_hcc_managed_name');
652
+ if (rootEvidence(ctx.root, hccRoot).state !== 'match') {
653
+ return skip('hcc_root_mismatch', { hcc_root: hccRoot });
654
+ }
655
+
656
+ const managedPanes = new Set(runtimeSessions.map((s) => s.pane).filter(Boolean));
657
+ const managedPeers = new Set();
658
+ for (const session of runtimeSessions) {
659
+ if (session.id) managedPeers.add(session.id);
660
+ if (session.peer_id) managedPeers.add(session.peer_id);
661
+ }
662
+ if (managedPanes.has(actualPane) || managedPanes.has(candidate.runtime_target)) {
663
+ return skip('runtime_managed');
664
+ }
665
+ if (candidate.source === 'binding' && (managedPeers.has(candidate.peer) || managedPeers.has(candidate.runtime_session_id))) {
666
+ return skip('runtime_managed');
667
+ }
668
+ const stored = candidate.source === 'binding'
669
+ ? candidate.authority
670
+ : rebindCandidateAuthority(candidate);
671
+ const bindingValidation = candidate.source === 'binding'
672
+ ? options.bindingValidation
673
+ : null;
674
+ const deadValidation = candidate.source !== 'binding' && options.freshEvidence?.state === 'dead'
675
+ ? validateTmuxGcDeadRebindEvidence(candidate)
676
+ : null;
677
+ const validation = bindingValidation || deadValidation || validateTmuxDestructiveEvidence(
678
+ stored, observeTmuxConditionalTarget(target)
679
+ );
680
+ if (!validation.ok) return skip(validation.reason);
681
+ const validationMode = bindingValidation?.mode || (deadValidation ? 'dead_process' : 'live_process');
682
+ const clientCount = validation.observed?.clients?.count;
683
+ return {
684
+ ok: true,
685
+ session: actualSession,
686
+ pane: actualPane,
687
+ hcc_root: hccRoot || null,
688
+ client_count: clientCount,
689
+ stored: validation.stored || stored,
690
+ gc_validation_mode: validationMode,
691
+ dead_process_mode: validationMode === 'dead_process'
692
+ };
693
+ }
694
+
695
+ async function cmdTmux(ctx, args) {
696
+ const sub = args[0];
697
+ if (!sub || wantsHelp(args)) return helpTmux();
698
+ if (sub !== 'gc') throw new CliError('BAD_ARGS', `Unknown tmux command: ${sub}`);
699
+
700
+ const opts = parseOpts(args.slice(1), { booleans: ['yes', 'dry-run'] });
701
+ validateOpts('tmux gc', opts, ['peer', 'older-than', 'yes', 'dry-run']);
702
+ if (opts.yes && opts['dry-run']) throw new CliError('BAD_ARGS', 'Use either --yes or --dry-run, not both');
703
+
704
+ const dryRun = !opts.yes;
705
+ const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
706
+ const plan = await planTmuxGc(ctx, opts);
707
+ const removed = [];
708
+ if (!dryRun) {
709
+ const runtimeSessions = await managedRuntimeSessions(ctx);
710
+ const db = connect(ctx);
711
+ try {
712
+ for (const candidate of plan.candidates) {
713
+ let currentEvidence = null;
714
+ let bindingValidation = null;
715
+ if (candidate.source === 'binding') {
716
+ const currentSubject = tmuxGcBindingSubjectFromDb(db, ctx, candidate.peer);
717
+ if (!sameTmuxGcBindingSubject(candidate.gc_validation_subject, currentSubject)) {
718
+ plan.skipped.push({ ...candidate, reason: 'tmux_binding_subject_changed', revalidated: true });
719
+ continue;
720
+ }
721
+ bindingValidation = validateTmuxGcBindingCandidate(currentSubject);
722
+ if (!bindingValidation.ok || bindingValidation.mode !== candidate.gc_validation_mode) {
723
+ plan.skipped.push({
724
+ ...candidate,
725
+ reason: bindingValidation.ok ? 'tmux_binding_validation_mode_changed' : bindingValidation.reason,
726
+ revalidated: true
727
+ });
728
+ continue;
729
+ }
730
+ } else {
731
+ currentEvidence = observeRebindEventEvidence(ctx, candidate);
732
+ }
733
+ if (candidate.source !== 'binding' && currentEvidence.state !== 'dead') {
734
+ plan.skipped.push({
735
+ ...candidate,
736
+ reason: currentEvidence.state === 'live' ? 'peer_live' : currentEvidence.reason,
737
+ evidence_state: currentEvidence.state,
738
+ revalidated: true
739
+ });
740
+ continue;
741
+ }
742
+ const valid = validateTmuxGcCandidate(ctx, candidate, runtimeSessions, {
743
+ freshEvidence: currentEvidence,
744
+ bindingValidation
745
+ });
746
+ if (!valid.ok) {
747
+ plan.skipped.push({ ...candidate, reason: valid.reason, revalidated: true });
748
+ continue;
749
+ }
750
+ const eventPayload = auditPayload({
751
+ actor,
752
+ target: candidate.peer,
753
+ admin: true,
754
+ peer: candidate.peer,
755
+ tmux_session: candidate.session,
756
+ runtime_target: candidate.runtime_target,
757
+ reason: candidate.reason,
758
+ older_than_days: plan.older_than_days
759
+ });
760
+ if (candidate.source === 'binding') {
761
+ try {
762
+ const t = now();
763
+ finalizeTmuxGcBindingMutation({
764
+ db,
765
+ plannedSubject: candidate.gc_validation_subject,
766
+ readSubject: () => tmuxGcBindingSubjectFromDb(db, ctx, candidate.peer),
767
+ sameSubject: sameTmuxGcBindingSubject,
768
+ conditionalKill: () => conditionalTmuxKill(runBoundedTmuxGcCommand, valid.stored, {
769
+ requireDeadPane: valid.dead_process_mode
770
+ }),
771
+ casBinding: (subject) => casDetachTmuxGcBinding(db, subject, t),
772
+ updatePeer: (subject) => casDetachTmuxGcPeer(db, subject, t)
773
+ });
774
+ } catch (error) {
775
+ plan.skipped.push({
776
+ ...candidate,
777
+ reason: error?.code || 'tmux_gc_binding_finalization_failed',
778
+ error: error?.message || String(error),
779
+ revalidated: true
780
+ });
781
+ continue;
782
+ }
783
+ addEvent(db, 'tmux.session.gc', actor, null, eventPayload);
784
+ removed.push(candidate);
785
+ continue;
786
+ }
787
+ try {
788
+ conditionalTmuxKill(runTmux, valid.stored, {
789
+ requireDeadPane: valid.dead_process_mode
790
+ });
791
+ } catch (error) {
792
+ plan.skipped.push({
793
+ ...candidate,
794
+ reason: error?.code || 'tmux_conditional_kill_failed',
795
+ error: error?.message || String(error),
796
+ revalidated: true
797
+ });
798
+ continue;
799
+ }
800
+ tx(db, () => {
801
+ addEvent(db, 'tmux.session.gc', actor, null, eventPayload);
802
+ removed.push(candidate);
803
+ });
804
+ }
805
+ } finally {
806
+ db.close();
807
+ }
808
+ }
809
+
810
+ const data = { dry_run: dryRun, older_than_days: plan.older_than_days, peer: plan.peer, candidates: plan.candidates, skipped: plan.skipped, removed };
811
+ printResult(ctx, data, (r) => {
812
+ const rows = dryRun ? r.candidates : r.removed;
813
+ const title = dryRun
814
+ ? `tmux gc dry-run: ${rows.length} removable hcc-managed session${rows.length === 1 ? '' : 's'}`
815
+ : `tmux gc removed ${rows.length} hcc-managed session${rows.length === 1 ? '' : 's'}`;
816
+ if (!rows.length) return `${title}\n nothing to clean`;
817
+ return [
818
+ title,
819
+ table(rows, [
820
+ { label: 'peer', value: (row) => row.peer },
821
+ { label: 'session', value: (row) => row.session },
822
+ { label: 'pane', value: (row) => row.pane },
823
+ { label: 'age', value: (row) => `${row.age_days}d` },
824
+ { label: 'reason', value: (row) => row.reason }
825
+ ]),
826
+ dryRun ? 'run again with --yes to delete only these DB-proven hcc-managed tmux sessions' : ''
827
+ ].filter(Boolean).join('\n');
828
+ });
829
+ }
830
+
831
+ return {
832
+ cmdScan, cmdTmux,
833
+ tmuxSessionNameForTarget, tmuxSessionCreationToken, tmuxSessionId, tmuxPaneForTarget,
834
+ managedRuntimeSessions, parseJsonObject,
835
+ planTmuxGc, validateTmuxGcCandidate,
836
+ strictTmuxClientObservation, tmuxAttachmentAuthority
837
+ };
838
+ }