@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,1361 @@
1
+ // tmux-backed terminal sessions, extracted from lib/web/runtime-main.mjs.
2
+ // DB-proven kill/rebind plans, destructive-evidence guards, attach + managed
3
+ // start (PTY/tmux bridges), restore/re-adopt after restart, session I/O,
4
+ // resize, and dead-peer reaping for hcc-managed tmux sessions.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { setTimeout as sleep } from 'node:timers/promises';
11
+ import { CliError } from '../shared/errors.mjs';
12
+ import { tx } from '../db/schema.mjs';
13
+ import { required } from '../cli-args.mjs';
14
+ import { readClockGraceUntil } from '../shared/clock-grace.mjs';
15
+ import { peerEvidenceAllowsReap } from '../core/peers/evidence.mjs';
16
+ import { conditionalTmuxKill, conditionalTmuxRename, prepareTmuxRestartBinding, rollbackTmuxRestartBinding, validateTmuxDestructiveEvidence } from '../core/peers/tmux-safety.mjs';
17
+ import { LAUNCH_FINGERPRINT_ENV, PROVIDER_STATE_ENV, childSessionEnv, isRelaunchableProviderSession, isolatedEnvCommandArgs, launchFingerprint, providerRestartReason } from '../core/sessions/launch.mjs';
18
+ import { defaultSessionCommand, inferPeerKind } from '../integrations/providers.mjs';
19
+ import { contextForProject } from '../runtime/paths.mjs';
20
+ import { detectBranch } from '../project-context.mjs';
21
+ import { ensureTmuxAvailable, runTmux, tmuxCapturePane, tmuxEnvironmentArgs, tmuxHasSession, tmuxKillSession, tmuxLaunchFingerprint, tmuxListSessionNames, tmuxManagedSessionName, tmuxManagedSessionNameMatches, tmuxPaneInfo, tmuxProviderState, tmuxSendLiteral, tmuxSessionEnvironmentValue, tmuxSessionHasClients } from '../tmux.mjs';
22
+
23
+ export function createTmuxSessions(deps) {
24
+ const {
25
+ ctx, sessions,
26
+ broadcast, closeSessionClients, hasOpenClients,
27
+ sessionKey, sessionsForProject, resolveSessionPeerId,
28
+ startTmuxStream, stopTmuxStream, refreshTmuxSnapshot,
29
+ connectWebProject, nextProjectSessionId, getSession,
30
+ runtimeProjectContexts,
31
+ scanExternalSessions, adoptExternalSession, removeExternalBufferFiles,
32
+ now, addEvent, auditPayload, tx,
33
+ canonicalRoot, liveProcessIdentity, rootEvidence,
34
+ observePeerEvidence, mutatePeerWithEvidence, observeClockSafetyOrThrow,
35
+ strictTmuxClientObservation, tmuxAttachmentAuthority,
36
+ tmuxPaneForTarget, tmuxSessionCreationToken, tmuxSessionId,
37
+ isProjectManagedTmuxSession, redactedLogText, bufferDirectory,
38
+ UNKNOWN_EVIDENCE_GRACE_SEC, BUFS_DIR_NAME, CLI_NAME,
39
+ requestActorPeer, requestSource,
40
+ upsertPeer, upsertCanonicalPeerBinding,
41
+ findProviderSessionBinding, providerOwnerEvidenceFromDb,
42
+ shellCommand, detectBranch
43
+ } = deps;
44
+
45
+ // ── Serialize + broadcast helpers ─────────────────────────────────────────
46
+ function detachTmuxSession(session, status = 'detached') {
47
+ stopTmuxStream(session);
48
+ if (session.exitPoller) { clearInterval(session.exitPoller); session.exitPoller = null; }
49
+
50
+ session.status = status;
51
+ session.exitedAt = now();
52
+ broadcast(session, { type: 'exit', event: { reason: status } });
53
+ closeSessionClients(session);
54
+ const pctx = session.ctx || contextForProject(session.root || ctx.root, null, { json: ctx.json });
55
+ sessions.delete(sessionKey(pctx, session.id));
56
+ const db = connectWebProject(pctx);
57
+ try {
58
+ const t = now();
59
+ const peerId = resolveSessionPeerId(db, session) || session.id;
60
+ // Do not bump last_seen_at on death: preserve the true last-seen time so a
61
+ // just-detached owner is not misread as freshly active (hb-01).
62
+ db.prepare('UPDATE peers SET status = ? WHERE id IN (?, ?)').run(status, session.id, peerId);
63
+ db.prepare(`
64
+ UPDATE peer_bindings
65
+ SET runtime_target = NULL, updated_at = ?
66
+ WHERE peer IN (?, ?)
67
+ OR runtime_session_id = ?
68
+ OR (? IS NOT NULL AND runtime_target = ?)
69
+ `).run(t, session.id, peerId, session.id, session.pane || null, session.pane || null);
70
+ addEvent(db, status === 'exited' ? 'tmux.session.exited' : 'tmux.session.detached', peerId, null, auditPayload({
71
+ actor: peerId,
72
+ target: peerId,
73
+ source: session.auditSource || 'runtime',
74
+ runtime_session_id: session.id,
75
+ pane: session.pane
76
+ }));
77
+ } finally {
78
+ db.close();
79
+ }
80
+ }
81
+
82
+ // Build the escape that places + shows/hides the cursor at a viewport cell,
83
+ // used only to seed the initial snapshot (live output carries its own cursor).
84
+ function tmuxSessionNameForPane(pane) {
85
+ if (!pane) return null;
86
+ try {
87
+ return runTmux(['display-message', '-p', '-t', pane, '#{session_name}']).trim() || null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function detachRuntimeSessionForPane(projectCtx, pane, status = 'detached') {
94
+ if (!pane) return;
95
+ for (const session of [...sessionsForProject(projectCtx)]) {
96
+ if (session.type === 'tmux' && session.pane === pane) {
97
+ detachTmuxSession(session, status);
98
+ }
99
+ }
100
+ }
101
+
102
+ function openClientCountForPane(projectCtx, pane) {
103
+ let count = 0;
104
+ for (const session of [...sessionsForProject(projectCtx)]) {
105
+ if (session.type === 'tmux' && session.pane === pane && hasOpenClients(session)) count += 1;
106
+ }
107
+ return count;
108
+ }
109
+
110
+ function tmuxClientObservation(sessionName) {
111
+ return strictTmuxClientObservation(sessionName);
112
+ }
113
+
114
+ function observeTmuxDestructiveEvidence(projectCtx, runtimeTarget) {
115
+ let paneInfo = null;
116
+ try { paneInfo = tmuxPaneInfo(runtimeTarget); } catch {}
117
+ const pane = paneInfo?.pane || tmuxPaneForTarget(runtimeTarget);
118
+ const sessionName = tmuxSessionNameForPane(runtimeTarget);
119
+ return {
120
+ session: sessionName,
121
+ session_created: tmuxSessionCreationToken(sessionName),
122
+ session_id: tmuxSessionId(sessionName),
123
+ root: canonicalRoot(tmuxSessionEnvironmentValue(sessionName, 'HCC_ROOT')),
124
+ pane,
125
+ process_identity: liveProcessIdentity(paneInfo?.pid),
126
+ clients: tmuxClientObservation(sessionName),
127
+ expected_root: canonicalRoot(projectCtx.root)
128
+ };
129
+ }
130
+
131
+ function tmuxAttachmentEvidence(db, peerId, runtimeTarget) {
132
+ return tmuxAttachmentAuthority(db, peerId, runtimeTarget);
133
+ }
134
+
135
+ function assertTmuxDestructiveEvidence(stored, observed, details = {}, options = {}) {
136
+ const validation = validateTmuxDestructiveEvidence(stored, observed, options);
137
+ if (validation.ok) return;
138
+ throw new CliError('TMUX_DESTRUCTIVE_EVIDENCE_INVALID',
139
+ `Refusing destructive tmux action: ${validation.reason}`,
140
+ { ...details, reason: validation.reason });
141
+ }
142
+
143
+ function attachmentEvidenceForPane(projectCtx, paneInfo) {
144
+ const expectedRoot = canonicalRoot(projectCtx.root);
145
+ let observed = observeTmuxDestructiveEvidence(projectCtx, paneInfo.pane);
146
+ if (observed.session && !observed.root) {
147
+ runTmux(['set-environment', '-t', observed.session, 'HCC_ROOT', expectedRoot]);
148
+ observed = observeTmuxDestructiveEvidence(projectCtx, paneInfo.pane);
149
+ }
150
+ if (!observed.session || !observed.session_created || !observed.session_id || observed.root !== expectedRoot ||
151
+ observed.pane !== paneInfo.pane || !observed.process_identity) {
152
+ throw new CliError('TMUX_ATTACH_EVIDENCE_INCOMPLETE',
153
+ 'Cannot attach tmux pane without complete immutable session evidence', {
154
+ pane: paneInfo.pane,
155
+ tmux_session: observed.session,
156
+ hcc_root: observed.root,
157
+ expected_root: expectedRoot
158
+ });
159
+ }
160
+ return {
161
+ session: observed.session,
162
+ session_created: observed.session_created,
163
+ session_id: observed.session_id,
164
+ root: observed.root,
165
+ pane: observed.pane,
166
+ process_identity: observed.process_identity
167
+ };
168
+ }
169
+
170
+ function oldTmuxEventEvidence(projectCtx, sessionName, runtimeTarget) {
171
+ let paneInfo = null;
172
+ try { paneInfo = tmuxPaneInfo(runtimeTarget); } catch {}
173
+ const processIdentity = liveProcessIdentity(paneInfo?.pid);
174
+ const hccRoot = canonicalRoot(tmuxSessionEnvironmentValue(sessionName, 'HCC_ROOT'));
175
+ const sessionCreated = tmuxSessionCreationToken(sessionName);
176
+ const sessionId = tmuxSessionId(sessionName);
177
+ return {
178
+ ...(paneInfo?.pane ? { old_pane: paneInfo.pane } : {}),
179
+ ...(processIdentity ? { old_process_identity: processIdentity } : {}),
180
+ ...(hccRoot ? { old_hcc_root: hccRoot } : {}),
181
+ ...(sessionCreated ? { old_tmux_session_created: sessionCreated } : {}),
182
+ ...(sessionId ? { old_tmux_session_id: sessionId } : {})
183
+ };
184
+ }
185
+
186
+ function addRebindCleanupFailedEvent(projectCtx, db, actor, payload) {
187
+ if (!db) return;
188
+ const evidence = oldTmuxEventEvidence(
189
+ projectCtx,
190
+ payload.old_tmux_session,
191
+ payload.old_runtime_target
192
+ );
193
+ addEvent(db, 'tmux.session.rebind_cleanup_failed', actor, null, auditPayload({
194
+ actor,
195
+ target: payload.old_peer || payload.target_peer || null,
196
+ admin: true,
197
+ ...evidence,
198
+ ...payload
199
+ }));
200
+ }
201
+
202
+ function oldTmuxRebindTarget(projectCtx, oldTarget, newTarget) {
203
+ if (!oldTarget || oldTarget === newTarget) return false;
204
+ const oldSessionName = tmuxSessionNameForPane(oldTarget);
205
+ const oldSessionRoot = tmuxSessionEnvironmentValue(oldSessionName, 'HCC_ROOT');
206
+ if (!isProjectManagedTmuxSession(projectCtx, oldSessionName, oldSessionRoot)) return false;
207
+ const newSessionName = tmuxSessionNameForPane(newTarget);
208
+ if (oldSessionName === newSessionName) return false;
209
+ return { oldSessionName, newSessionName };
210
+ }
211
+
212
+ function tmuxSessionClientCountForStop(sessionName) {
213
+ const clients = tmuxClientObservation(sessionName);
214
+ if (clients.state !== 'known') {
215
+ throw new CliError('TMUX_CLIENT_QUERY_FAILED',
216
+ `Refusing destructive tmux action because clients for ${sessionName || 'unknown session'} could not be queried`);
217
+ }
218
+ return clients.count;
219
+ }
220
+
221
+ function safeTmuxKillPlan(projectCtx, db, peerId, expectedTarget) {
222
+ if (!peerId) {
223
+ throw new CliError('BAD_REQUEST', 'peer id required for tmux kill');
224
+ }
225
+ const binding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(peerId);
226
+ if (!binding || binding.transport !== 'tmux' || !binding.runtime_target) {
227
+ throw new CliError('TMUX_KILL_NOT_MANAGED', `No DB-proven tmux runtime binding for ${peerId}`);
228
+ }
229
+ if (expectedTarget && binding.runtime_target !== expectedTarget) {
230
+ throw new CliError('TMUX_KILL_TARGET_CHANGED', `tmux runtime target for ${peerId} changed`, {
231
+ peer: peerId,
232
+ expected_runtime_target: expectedTarget,
233
+ runtime_target: binding.runtime_target
234
+ });
235
+ }
236
+
237
+ const stored = tmuxAttachmentEvidence(db, peerId, binding.runtime_target);
238
+ const observed = observeTmuxDestructiveEvidence(projectCtx, binding.runtime_target);
239
+ const actualSession = observed.session;
240
+ const actualPane = observed.pane;
241
+ if (!actualSession || !actualPane) {
242
+ throw new CliError('TMUX_KILL_TARGET_MISSING', `tmux runtime target for ${peerId} is not running`);
243
+ }
244
+
245
+ const expectedSession = tmuxManagedSessionName(projectCtx, peerId);
246
+ const sessionRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');
247
+ if (!tmuxManagedSessionNameMatches(projectCtx, actualSession, peerId, sessionRoot)) {
248
+ throw new CliError('TMUX_KILL_NOT_HCC_MANAGED', `Refusing to kill non-managed tmux session ${actualSession}`, {
249
+ peer: peerId,
250
+ expected_session: expectedSession,
251
+ actual_session: actualSession,
252
+ runtime_target: binding.runtime_target
253
+ });
254
+ }
255
+
256
+ assertTmuxDestructiveEvidence(stored, observed, {
257
+ peer: peerId,
258
+ runtime_target: binding.runtime_target
259
+ });
260
+
261
+ return {
262
+ binding,
263
+ stored,
264
+ session: actualSession,
265
+ pane: actualPane,
266
+ runtime_target: binding.runtime_target,
267
+ hcc_root: observed.root
268
+ };
269
+ }
270
+
271
+ function executeTmuxKillPlan(projectCtx, plan) {
272
+ const observed = observeTmuxDestructiveEvidence(projectCtx, plan.runtime_target);
273
+ assertTmuxDestructiveEvidence(plan.stored, observed, {
274
+ peer: plan.binding?.peer || null,
275
+ runtime_target: plan.runtime_target
276
+ });
277
+ conditionalTmuxKill(runTmux, plan.stored);
278
+ }
279
+
280
+ function killDbProvenTmuxSession(projectCtx, db, peerId, expectedTarget = null) {
281
+ const plan = safeTmuxKillPlan(projectCtx, db, peerId, expectedTarget);
282
+ executeTmuxKillPlan(projectCtx, plan);
283
+ return plan;
284
+ }
285
+
286
+ function safeOldTmuxRebindPlan(projectCtx, db, oldPeer, oldTarget, newTarget, actor, opts = {}) {
287
+ const target = oldTmuxRebindTarget(projectCtx, oldTarget, newTarget);
288
+ if (!target) return null;
289
+ const { oldSessionName, newSessionName } = target;
290
+ const actualPane = tmuxPaneForTarget(oldTarget);
291
+ if (!actualPane) return null;
292
+ if (!oldPeer) {
293
+ throw new CliError('TMUX_REBIND_OLD_PEER_REQUIRED', 'old peer id required for tmux rebind cleanup');
294
+ }
295
+ if (db) {
296
+ const oldBinding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(oldPeer);
297
+ if (!oldBinding || oldBinding.transport !== 'tmux' || oldBinding.runtime_target !== oldTarget) {
298
+ addRebindCleanupFailedEvent(projectCtx, db, actor, {
299
+ reason: 'old_binding_runtime_target_changed',
300
+ old_peer: oldPeer,
301
+ old_runtime_target: oldTarget,
302
+ new_runtime_target: newTarget,
303
+ current_runtime_target: oldBinding?.runtime_target || null,
304
+ old_tmux_session: oldSessionName,
305
+ new_tmux_session: newSessionName || null
306
+ });
307
+ throw new CliError('TMUX_REBIND_OLD_TARGET_CHANGED', `tmux runtime target for ${oldPeer} changed before rebind cleanup`, {
308
+ old_peer: oldPeer,
309
+ expected_runtime_target: oldTarget,
310
+ runtime_target: oldBinding?.runtime_target || null
311
+ });
312
+ }
313
+ }
314
+ const expectedSession = tmuxManagedSessionName(projectCtx, oldPeer);
315
+ const allowedSession = opts.allowedSessionName || null;
316
+ const oldSessionRoot = tmuxSessionEnvironmentValue(oldSessionName, 'HCC_ROOT');
317
+ if (!tmuxManagedSessionNameMatches(projectCtx, oldSessionName, oldPeer, oldSessionRoot) &&
318
+ oldSessionName !== allowedSession) {
319
+ addRebindCleanupFailedEvent(projectCtx, db, actor, {
320
+ reason: 'not_hcc_managed_peer_session',
321
+ old_peer: oldPeer,
322
+ old_runtime_target: oldTarget,
323
+ new_runtime_target: newTarget,
324
+ expected_tmux_session: expectedSession,
325
+ allowed_tmux_session: allowedSession,
326
+ old_tmux_session: oldSessionName,
327
+ new_tmux_session: newSessionName || null
328
+ });
329
+ throw new CliError('TMUX_REBIND_NOT_HCC_MANAGED', `Refusing to rebind-cleanup non-managed tmux session ${oldSessionName}`, {
330
+ old_peer: oldPeer,
331
+ expected_session: expectedSession,
332
+ allowed_session: allowedSession,
333
+ actual_session: oldSessionName,
334
+ runtime_target: oldTarget
335
+ });
336
+ }
337
+ const attachedStored = tmuxAttachmentEvidence(db, oldPeer, oldTarget);
338
+ const observed = observeTmuxDestructiveEvidence(projectCtx, oldTarget);
339
+ const stored = allowedSession && observed.session === allowedSession &&
340
+ attachedStored.session === expectedSession
341
+ ? { ...attachedStored, session: allowedSession }
342
+ : attachedStored;
343
+ const webClientCount = openClientCountForPane(projectCtx, oldTarget);
344
+ assertTmuxDestructiveEvidence(stored, observed, {
345
+ old_peer: oldPeer,
346
+ old_runtime_target: oldTarget,
347
+ new_runtime_target: newTarget
348
+ }, { allowClients: Boolean(opts.force) });
349
+ const tmuxClientCount = observed.clients.count;
350
+ if ((webClientCount > 0 || tmuxClientCount > 0) && !opts.force) {
351
+ addRebindCleanupFailedEvent(projectCtx, db, actor, {
352
+ reason: 'has_clients',
353
+ old_peer: oldPeer,
354
+ old_runtime_target: oldTarget,
355
+ new_runtime_target: newTarget,
356
+ old_tmux_session: oldSessionName,
357
+ new_tmux_session: newSessionName || null,
358
+ web_client_count: webClientCount,
359
+ tmux_client_count: tmuxClientCount
360
+ });
361
+ throw new CliError('TMUX_REBIND_OLD_SESSION_IN_USE',
362
+ `Old tmux session ${oldSessionName} still has clients; detach clients or run ${CLI_NAME} tmux gc later.`,
363
+ {
364
+ old_runtime_target: oldTarget,
365
+ new_runtime_target: newTarget,
366
+ old_tmux_session: oldSessionName,
367
+ web_client_count: webClientCount,
368
+ tmux_client_count: tmuxClientCount
369
+ });
370
+ }
371
+ const eventEvidence = {
372
+ old_pane: stored.pane,
373
+ old_process_identity: stored.process_identity,
374
+ old_hcc_root: stored.root,
375
+ old_tmux_session_created: stored.session_created,
376
+ old_tmux_session_id: stored.session_id
377
+ };
378
+ if (db) {
379
+ addEvent(db, 'tmux.session.rebind_cleanup_pending', actor, null, auditPayload({
380
+ actor,
381
+ target: oldPeer,
382
+ admin: true,
383
+ old_peer: oldPeer,
384
+ old_runtime_target: oldTarget,
385
+ new_runtime_target: newTarget,
386
+ old_tmux_session: oldSessionName,
387
+ new_tmux_session: newSessionName || null,
388
+ expected_tmux_session: expectedSession,
389
+ allowed_tmux_session: allowedSession,
390
+ hcc_root: stored.root,
391
+ ...eventEvidence
392
+ }));
393
+ }
394
+ return {
395
+ oldPeer,
396
+ oldTarget,
397
+ newTarget,
398
+ oldSessionName,
399
+ newSessionName,
400
+ oldPane: actualPane,
401
+ expectedSession,
402
+ allowedSession,
403
+ hccRoot: stored.root,
404
+ stored,
405
+ webClientCount,
406
+ tmuxClientCount,
407
+ eventEvidence,
408
+ force: Boolean(opts.force)
409
+ };
410
+ }
411
+
412
+ function assertOldTmuxCanRebind(projectCtx, oldPeer, oldTarget, newTarget, actor, db = null, opts = {}) {
413
+ return safeOldTmuxRebindPlan(projectCtx, db, oldPeer, oldTarget, newTarget, actor, opts);
414
+ }
415
+
416
+ function killOldTmuxForRebind(projectCtx, plan, actor, db = null) {
417
+ if (!plan) return false;
418
+ const {
419
+ oldPeer,
420
+ oldTarget,
421
+ newTarget,
422
+ oldSessionName,
423
+ newSessionName,
424
+ webClientCount,
425
+ tmuxClientCount
426
+ } = plan;
427
+ const force = Boolean(plan.force);
428
+ let latestWebClientCount = webClientCount;
429
+ let latestTmuxClientCount = tmuxClientCount;
430
+
431
+ try {
432
+ latestWebClientCount = openClientCountForPane(projectCtx, oldTarget);
433
+ let observed = observeTmuxDestructiveEvidence(projectCtx, oldTarget);
434
+ assertTmuxDestructiveEvidence(plan.stored, observed, {
435
+ old_peer: oldPeer,
436
+ old_runtime_target: oldTarget,
437
+ new_runtime_target: newTarget
438
+ }, { allowClients: force });
439
+ latestTmuxClientCount = observed.clients.count;
440
+ if ((latestWebClientCount > 0 || latestTmuxClientCount > 0) && !force) {
441
+ throw new CliError('TMUX_REBIND_OLD_SESSION_IN_USE',
442
+ `Old tmux session ${oldSessionName} still has clients; detach clients or run ${CLI_NAME} tmux gc later.`,
443
+ {
444
+ old_runtime_target: oldTarget,
445
+ new_runtime_target: newTarget,
446
+ old_tmux_session: oldSessionName,
447
+ web_client_count: latestWebClientCount,
448
+ tmux_client_count: latestTmuxClientCount
449
+ });
450
+ }
451
+ detachRuntimeSessionForPane(projectCtx, oldTarget, 'detached');
452
+ observed = observeTmuxDestructiveEvidence(projectCtx, oldTarget);
453
+ assertTmuxDestructiveEvidence(plan.stored, observed, {
454
+ old_peer: oldPeer,
455
+ old_runtime_target: oldTarget,
456
+ new_runtime_target: newTarget
457
+ }, { allowClients: force });
458
+ conditionalTmuxKill(runTmux, plan.stored, { allowClients: force });
459
+ } catch (err) {
460
+ addRebindCleanupFailedEvent(projectCtx, db, actor, {
461
+ reason: err?.code || 'cleanup_failed',
462
+ error: err?.message || String(err),
463
+ old_peer: oldPeer,
464
+ old_runtime_target: oldTarget,
465
+ new_runtime_target: newTarget,
466
+ old_tmux_session: oldSessionName,
467
+ new_tmux_session: newSessionName || null,
468
+ web_client_count: latestWebClientCount,
469
+ tmux_client_count: latestTmuxClientCount,
470
+ ...(plan.eventEvidence || {})
471
+ });
472
+ throw err;
473
+ }
474
+ if (db) {
475
+ addEvent(db, 'tmux.session.rebound', actor, null, auditPayload({
476
+ actor,
477
+ target: oldPeer,
478
+ admin: true,
479
+ old_peer: oldPeer,
480
+ old_runtime_target: oldTarget,
481
+ new_runtime_target: newTarget,
482
+ old_tmux_session: oldSessionName,
483
+ new_tmux_session: newSessionName || null
484
+ }));
485
+ }
486
+ return true;
487
+ }
488
+
489
+ function providerSessionBindingMatches(a, b) {
490
+ if (!a || !b || a.provider !== b.provider) return false;
491
+ if (b.provider_session_id) return a.provider_session_id === b.provider_session_id;
492
+ if (b.provider_session_name) return a.provider_session_name === b.provider_session_name;
493
+ return false;
494
+ }
495
+
496
+ function attachTmuxSession(input) {
497
+ const pctx = input.projectCtx || ctx;
498
+ const id = input.id;
499
+ if (!id) throw new CliError('BAD_REQUEST', 'id required');
500
+ const actorPeer = requestActorPeer(input, id);
501
+ const auditSource = requestSource(input, input.autoAttach ? 'runtime' : 'web');
502
+ const info = tmuxPaneInfo(input.pane || null);
503
+ if (info.dead) throw new CliError('TMUX_PANE_DEAD', `tmux pane is not running: ${info.pane}`);
504
+
505
+ for (const existing of [...sessions.values()]) {
506
+ if (existing.type === 'tmux' && existing.pane === info.pane && existing.id !== id) {
507
+ if (!input.force) {
508
+ throw new CliError('TMUX_PANE_IN_USE', `tmux pane ${info.pane} is already attached to ${existing.id}`, {
509
+ pane: info.pane,
510
+ peer: existing.id
511
+ });
512
+ }
513
+ detachTmuxSession(existing, 'detached');
514
+ }
515
+ }
516
+
517
+ const key = sessionKey(pctx, id);
518
+ const existing = sessions.get(key);
519
+ if (existing && existing.status === 'running') {
520
+ if (existing.type === 'tmux' && existing.pane === info.pane) return existing;
521
+ if (existing !== input.replaceSession) {
522
+ throw new CliError('SESSION_EXISTS', `Session ${id} is already running`);
523
+ }
524
+ }
525
+
526
+ const kind = inferPeerKind(id, input.kind || null, info.command);
527
+ const role = input.role || 'peer';
528
+ const cwd = path.resolve(input.cwd || info.cwd || pctx.root);
529
+ const command = input.command || `tmux ${info.pane} (${info.command})`;
530
+ const branch = detectBranch(cwd);
531
+ const attachedEvidence = attachmentEvidenceForPane(pctx, info);
532
+ const binding = input.binding || {};
533
+ const nextBinding = {
534
+ peer: id,
535
+ provider: binding.provider || kind,
536
+ provider_session_id: binding.provider_session_id || null,
537
+ provider_session_name: binding.provider_session_name || null,
538
+ resume_mode: binding.resume_mode || 'attached',
539
+ resume_arg: binding.resume_arg || info.pane,
540
+ command: binding.command || command,
541
+ transport: 'tmux',
542
+ runtime_session_id: id,
543
+ runtime_target: info.pane
544
+ };
545
+
546
+ const captured = tmuxCapturePane(info.pane);
547
+ const session = {
548
+ id,
549
+ peerId: id,
550
+ actorPeer,
551
+ auditSource,
552
+ actionTokens: new Set(),
553
+ root: pctx.root,
554
+ ctx: pctx,
555
+ kind,
556
+ role,
557
+ command,
558
+ cwd,
559
+ pid: info.pid,
560
+ type: 'tmux',
561
+ pane: info.pane,
562
+ status: 'running',
563
+ createdAt: now(),
564
+ exitedAt: null,
565
+ buffer: captured,
566
+ clients: new Set(),
567
+ streamPoller: null,
568
+ streamFd: null,
569
+ pipeFile: null,
570
+ replacePoller: null,
571
+ lastBroadcastTime: 0,
572
+ exitPoller: null
573
+ };
574
+ sessions.set(key, session);
575
+ try {
576
+ startTmuxStream(session);
577
+ if (!session.streamPoller && !session.replacePoller) {
578
+ throw new CliError('TMUX_STREAM_ERROR', `Could not establish output tracking for ${info.pane}`);
579
+ }
580
+ } catch (err) {
581
+ stopTmuxStream(session);
582
+ if (sessions.get(key) === session) sessions.delete(key);
583
+ throw err;
584
+ }
585
+ let rebindOldTarget = null;
586
+ let rebindOldPeer = null;
587
+ let rebindOldPlan = null;
588
+ let rebindOldBindingSubject = null;
589
+
590
+ // Detect pane death (retry 3 times before detaching — handles Ctrl+C transient states)
591
+ let deadCount = 0;
592
+ session.exitPoller = setInterval(() => {
593
+ try {
594
+ const fresh = tmuxPaneInfo(session.pane);
595
+ if (fresh.dead) {
596
+ deadCount++;
597
+ if (deadCount >= 3) detachTmuxSession(session, 'exited');
598
+ } else {
599
+ deadCount = 0;
600
+ }
601
+ } catch (err) {
602
+ // sess-03: only a definitive "pane/session is gone" error counts toward
603
+ // detach; a transient tmux failure (busy server, momentary hiccup) must
604
+ // not detach a live session after 3 attempts.
605
+ const message = err?.code === 'TMUX_ERROR' ? String(err.message || '') : '';
606
+ const definitivelyGone = /can't find pane|can't find session|no server running/i.test(message);
607
+ if (definitivelyGone) {
608
+ deadCount++;
609
+ if (deadCount >= 3) detachTmuxSession(session, 'exited');
610
+ }
611
+ }
612
+ }, 3000);
613
+
614
+ let db = null;
615
+ try {
616
+ db = connectWebProject(pctx);
617
+ if (input.rebindOldTmux && !input.skipProviderRebindCleanup &&
618
+ (nextBinding.provider_session_id || nextBinding.provider_session_name)) {
619
+ const existingPeerBinding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(id);
620
+ const conflictBinding = findProviderSessionBinding(db, nextBinding);
621
+ const oldBinding = [existingPeerBinding, conflictBinding]
622
+ .filter((row) => providerSessionBindingMatches(row, nextBinding))
623
+ .find((row) => row?.transport === 'tmux' && row.runtime_target && row.runtime_target !== info.pane);
624
+ if (oldBinding) {
625
+ rebindOldTarget = oldBinding.runtime_target;
626
+ rebindOldPeer = oldBinding.peer;
627
+ rebindOldBindingSubject = { ...oldBinding };
628
+ rebindOldPlan = assertOldTmuxCanRebind(pctx, rebindOldPeer, rebindOldTarget, info.pane, id, db, {
629
+ force: Boolean(input.providerForce)
630
+ });
631
+ }
632
+ }
633
+ tx(db, () => {
634
+ if (rebindOldBindingSubject) {
635
+ const currentOldBinding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(rebindOldPeer);
636
+ if (JSON.stringify(currentOldBinding || null) !== JSON.stringify(rebindOldBindingSubject)) {
637
+ throw new CliError('SUBJECT_CHANGED', 'tmux rebind binding changed during evidence validation', {
638
+ retryable: true,
639
+ old_peer: rebindOldPeer
640
+ });
641
+ }
642
+ }
643
+ upsertPeer(db, {
644
+ id,
645
+ kind,
646
+ role,
647
+ worktree: cwd,
648
+ branch,
649
+ pid: info.pid,
650
+ processIdentity: attachedEvidence.process_identity,
651
+ status: 'running',
652
+ capabilities: 'tmux'
653
+ });
654
+ const providerForce = Boolean(input.providerForce);
655
+ const canonical = upsertCanonicalPeerBinding(db, nextBinding, providerForce, {
656
+ override: Boolean(input.rebindOldTmux && providerForce)
657
+ });
658
+ session.peerId = canonical.peer;
659
+ session.binding = { ...canonical.binding };
660
+ addEvent(db, 'tmux.session.attached', actorPeer, null, auditPayload({
661
+ actor: actorPeer,
662
+ target: session.peerId || id,
663
+ source: auditSource,
664
+ admin: actorPeer !== (session.peerId || id),
665
+ pane: info.pane,
666
+ command,
667
+ cwd,
668
+ pid: info.pid,
669
+ tmux_session: attachedEvidence.session,
670
+ tmux_session_created: attachedEvidence.session_created,
671
+ tmux_session_id: attachedEvidence.session_id,
672
+ hcc_root: attachedEvidence.root,
673
+ process_identity: attachedEvidence.process_identity
674
+ }));
675
+ });
676
+ } catch (err) {
677
+ stopTmuxStream(session);
678
+ if (session.exitPoller) { clearInterval(session.exitPoller); session.exitPoller = null; }
679
+ if (sessions.get(key) === session) sessions.delete(key);
680
+ throw err;
681
+ } finally {
682
+ try { db?.close(); } catch {}
683
+ }
684
+ if (rebindOldTarget) {
685
+ try {
686
+ const eventDb = connectWebProject(pctx);
687
+ try {
688
+ killOldTmuxForRebind(pctx, rebindOldPlan, session.peerId || id, eventDb);
689
+ if (rebindOldPeer && rebindOldPeer !== id) {
690
+ const actor = actorPeer || session.peerId || id;
691
+ addEvent(eventDb, 'provider.session.rebound', actor, null, auditPayload({
692
+ actor,
693
+ target: rebindOldPeer,
694
+ source: auditSource,
695
+ admin: true,
696
+ from_peer: rebindOldPeer,
697
+ to_peer: id,
698
+ old_runtime_target: rebindOldTarget,
699
+ new_runtime_target: info.pane
700
+ }));
701
+ }
702
+ } finally {
703
+ eventDb.close();
704
+ }
705
+ } catch (err) {
706
+ session.warning = {
707
+ code: err?.code || 'TMUX_REBIND_CLEANUP_FAILED',
708
+ message: err?.message || String(err),
709
+ old_peer: rebindOldPeer,
710
+ old_runtime_target: rebindOldTarget
711
+ };
712
+ }
713
+ }
714
+ return session;
715
+ }
716
+
717
+ // hb-04: real terminal I/O is a liveness signal. Throttle-refresh the peer's
718
+ // last_seen_at (~30s) so an actively-used but hook-quiet terminal is not
719
+ // judged stale/takeover-able. Never throws — I/O hot paths stay safe.
720
+ function refreshPeerIoHeartbeat(session) {
721
+ try {
722
+ const t = now();
723
+ if (session.lastIoRefreshAt && t - session.lastIoRefreshAt < 30) return;
724
+ session.lastIoRefreshAt = t;
725
+ const peerId = session.peerId || session.id;
726
+ if (!peerId) return;
727
+ const ioDb = connectWebProject(session.ctx || ctx);
728
+ try {
729
+ ioDb.prepare('UPDATE peers SET last_seen_at = ? WHERE id = ?').run(t, peerId);
730
+ } finally {
731
+ ioDb.close();
732
+ }
733
+ } catch {}
734
+ }
735
+
736
+ function writeSessionInput(session, data) {
737
+ if (session.type === 'external') {
738
+ try { fs.appendFileSync(session.inFile, data); } catch {}
739
+ } else if (session.type === 'tmux') {
740
+ tmuxSendLiteral(session.pane, data);
741
+ scheduleTmuxInputRefresh(session);
742
+ } else {
743
+ session.pty.write(data);
744
+ }
745
+ refreshPeerIoHeartbeat(session);
746
+ }
747
+
748
+ function scheduleTmuxInputRefresh(session) {
749
+ if (session.type !== 'tmux' || !session.pane) return;
750
+ if (session.inputRefreshTimer) return;
751
+ session.inputRefreshTimer = setTimeout(() => {
752
+ session.inputRefreshTimer = null;
753
+ if (session.status === 'running') broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
754
+ }, 80);
755
+ }
756
+
757
+ function resizeSession(session, cols, rows) {
758
+ if (session.type === 'external') {
759
+ try { fs.appendFileSync(session.resizeFile, JSON.stringify({ cols, rows }) + '\n'); } catch {}
760
+ } else if (session.type === 'tmux') {
761
+ // Resize the tmux window to match the browser terminal so the captured
762
+ // mirror has identical geometry — a prerequisite for accurate cursor
763
+ // placement. Older tmux without resize-window simply keeps native size.
764
+ session.cols = cols;
765
+ session.rows = rows;
766
+ try {
767
+ if (!session.windowSizeManual) {
768
+ runTmux(['set-window-option', '-t', session.pane, 'window-size', 'manual']);
769
+ session.windowSizeManual = true;
770
+ }
771
+ runTmux(['resize-window', '-t', session.pane, '-x', String(cols), '-y', String(rows)]);
772
+ } catch {
773
+ // tmux too old or pane gone; keep mirroring at the native pane size.
774
+ }
775
+ } else if (session.pty) {
776
+ session.pty.resize(cols, rows);
777
+ }
778
+ }
779
+
780
+ function startTmuxManagedSession(input) {
781
+ ensureTmuxAvailable({ autoInstall: false });
782
+ const pctx = input.projectCtx || ctx;
783
+ const kind = input.kind || 'shell';
784
+ const id = input.id || nextProjectSessionId(pctx, kind);
785
+ const actorPeer = requestActorPeer(input, id);
786
+ const auditSource = requestSource(input, 'web');
787
+ const role = input.role || 'peer';
788
+ const command = input.command || defaultSessionCommand(kind);
789
+ const cwd = path.resolve(input.cwd || pctx.root);
790
+ const sessionName = tmuxManagedSessionName(pctx, id);
791
+ let paneTarget = `${sessionName}:0.0`;
792
+ const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
793
+ const env = childSessionEnv({
794
+ HCC_PEER: id,
795
+ HCC_ROOT: pctx.root,
796
+ HCC_DB: pctx.dbPath,
797
+ TERM: 'xterm-256color'
798
+ }, callerEnv);
799
+ env[LAUNCH_FINGERPRINT_ENV] = launchFingerprint({ command, cwd, env });
800
+ let hasSession = tmuxHasSession(sessionName);
801
+ const relaunchableProvider = isRelaunchableProviderSession(kind, command, input.binding || {});
802
+ const oldTmuxTargetsForRebind = [];
803
+ const parkedOldTmuxSessions = [];
804
+ let createdTmuxSession = false;
805
+ let pendingOldSession = null;
806
+ let pendingRestartAudit = null;
807
+
808
+ function restoreParkedOldTmuxSessions() {
809
+ const failures = [];
810
+ const quarantined = [];
811
+ for (const parked of [...parkedOldTmuxSessions].reverse()) {
812
+ if (!tmuxHasSession(parked.parkedName)) {
813
+ failures.push(`parked session missing: ${parked.parkedName}`);
814
+ continue;
815
+ }
816
+ let quarantineName = null;
817
+ if (tmuxHasSession(parked.originalName)) {
818
+ quarantineName = `hcc-rollback-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
819
+ try {
820
+ runTmux(['rename-session', '-t', parked.originalName, quarantineName]);
821
+ } catch (err) {
822
+ failures.push(`could not quarantine failed replacement ${parked.originalName}: ${err?.message || err}`);
823
+ continue;
824
+ }
825
+ }
826
+ try {
827
+ runTmux(['rename-session', '-t', parked.parkedName, parked.originalName]);
828
+ } catch (err) {
829
+ failures.push(`could not restore ${parked.parkedName} to ${parked.originalName}: ${err?.message || err}`);
830
+ if (quarantineName && !tmuxHasSession(parked.originalName)) {
831
+ try { runTmux(['rename-session', '-t', quarantineName, parked.originalName]); } catch {}
832
+ }
833
+ continue;
834
+ }
835
+ if (quarantineName) {
836
+ try { tmuxKillSession(quarantineName); } catch { quarantined.push(quarantineName); }
837
+ }
838
+ }
839
+ return { failures, quarantined };
840
+ }
841
+
842
+ function restartExistingTmuxSession(reason) {
843
+ const existing = getSession(pctx, id);
844
+ const hasWebClients = hasOpenClients(existing);
845
+ const hasTmuxClients = tmuxSessionHasClients(sessionName);
846
+ if (hasWebClients || hasTmuxClients) {
847
+ const isEnvChange = reason === 'launch_environment_changed';
848
+ throw new CliError(
849
+ isEnvChange ? 'SESSION_ENV_CHANGED' : 'SESSION_IN_USE',
850
+ isEnvChange
851
+ ? `Session ${id} is already running with a different launch environment. Detach/close existing clients or run ${CLI_NAME} peer stop ${id}, then start it again.`
852
+ : `Session ${id} is still attached. Detach/close existing clients or run ${CLI_NAME} peer stop ${id}, then start it again.`,
853
+ {
854
+ peer: id,
855
+ tmux_session: sessionName,
856
+ reason
857
+ });
858
+ }
859
+ let oldTarget = null;
860
+ try {
861
+ oldTarget = tmuxPaneInfo(paneTarget).pane;
862
+ } catch {}
863
+ if (!oldTarget) {
864
+ throw new CliError('TMUX_DESTRUCTIVE_EVIDENCE_INVALID',
865
+ 'Refusing destructive tmux restart: pane target could not be resolved', {
866
+ peer: id,
867
+ tmux_session: sessionName,
868
+ reason
869
+ });
870
+ }
871
+ const authorityDb = connectWebProject(pctx);
872
+ let storedAuthority;
873
+ let bindingPreparation;
874
+ try {
875
+ storedAuthority = tmuxAttachmentEvidence(authorityDb, id, oldTarget);
876
+ assertTmuxDestructiveEvidence(
877
+ storedAuthority,
878
+ observeTmuxDestructiveEvidence(pctx, oldTarget),
879
+ { peer: id, tmux_session: sessionName, reason }
880
+ );
881
+ // The liveness reaper may have safely detached a dead provider while
882
+ // leaving its fallback tmux shell alive. Restore only that null binding
883
+ // after immutable tmux authority is verified so the rebind CAS can
884
+ // distinguish recovery from a foreign target change.
885
+ bindingPreparation = prepareTmuxRestartBinding(authorityDb, {
886
+ peer: id,
887
+ runtimeTarget: oldTarget,
888
+ nowSec: now()
889
+ });
890
+ if (!bindingPreparation.ok) {
891
+ throw new CliError('TMUX_REBIND_OLD_TARGET_CHANGED',
892
+ `tmux runtime target for ${id} changed before provider restart`, {
893
+ peer: id,
894
+ expected_runtime_target: oldTarget,
895
+ reason: bindingPreparation.reason
896
+ });
897
+ }
898
+ } finally {
899
+ authorityDb.close();
900
+ }
901
+ const parkedName = `${sessionName}-old-${Date.now().toString(36)}`.slice(0, 80);
902
+ try {
903
+ conditionalTmuxRename(runTmux, storedAuthority, parkedName);
904
+ } catch (err) {
905
+ let bindingRolledBack = true;
906
+ if (bindingPreparation?.restored) {
907
+ const rollbackDb = connectWebProject(pctx);
908
+ try {
909
+ bindingRolledBack = rollbackTmuxRestartBinding(rollbackDb, bindingPreparation);
910
+ } finally {
911
+ rollbackDb.close();
912
+ }
913
+ }
914
+ if (err instanceof CliError && err.code === 'TMUX_CONDITIONAL_RENAME_MISMATCH' && bindingRolledBack) throw err;
915
+ throw new CliError('TMUX_REBIND_PREPARE_FAILED', `Could not park old tmux session ${sessionName} before rebind: ${err.message}`, {
916
+ peer: id,
917
+ tmux_session: sessionName,
918
+ reason,
919
+ binding_rollback_failed: !bindingRolledBack
920
+ });
921
+ }
922
+ parkedOldTmuxSessions.push({ oldTarget, originalName: sessionName, parkedName });
923
+ if (oldTarget) oldTmuxTargetsForRebind.push({ oldPeer: id, oldTarget, allowedSessionName: parkedName });
924
+ if (existing) {
925
+ oldTarget = oldTarget || existing.pane || null;
926
+ // sess-06: do NOT tear down the in-memory session yet — keep it fully
927
+ // alive until the replacement tmux session exists, so a failed restart
928
+ // leaves the old session (stream/pollers/clients) intact.
929
+ pendingOldSession = existing;
930
+ }
931
+ hasSession = false;
932
+ pendingRestartAudit = { reason, oldTarget, parkedName };
933
+ }
934
+
935
+ if (hasSession && input.restartOnEnvChange) {
936
+ const existingFingerprint = tmuxLaunchFingerprint(sessionName);
937
+ if (existingFingerprint !== env[LAUNCH_FINGERPRINT_ENV]) {
938
+ restartExistingTmuxSession('launch_environment_changed');
939
+ }
940
+ }
941
+
942
+ if (hasSession && relaunchableProvider) {
943
+ // G4a: HCC_PROVIDER_STATE is a tmux-env hint that can be forged from
944
+ // inside the session. Before a destructive park-and-replace, require
945
+ // process evidence that the provider is really gone: the pane process
946
+ // must be dead, or have no live non-shell child (the provider wrapper
947
+ // leaves the pane as a bare shell after the provider exits). A session
948
+ // with a live provider child is never torn down.
949
+ const providerState = tmuxProviderState(sessionName);
950
+ const info = tmuxPaneInfo(paneTarget);
951
+ const ownerDb = connectWebProject(pctx);
952
+ let ownerEvidence;
953
+ try {
954
+ ownerEvidence = providerOwnerEvidenceFromDb(ownerDb, id);
955
+ } finally {
956
+ ownerDb.close();
957
+ }
958
+ const reason = providerRestartReason({
959
+ providerState,
960
+ ownerEvidence,
961
+ paneCommand: info.command
962
+ });
963
+ if (reason) restartExistingTmuxSession(reason);
964
+ }
965
+
966
+ let session;
967
+ let pane = null;
968
+ try {
969
+ if (!hasSession) {
970
+ const shell = callerEnv.SHELL || process.env.SHELL || 'bash';
971
+ const launch = shellCommand([...isolatedEnvCommandArgs(env), shell, '-c', command]);
972
+ const tmuxEnv = {
973
+ HCC_ROOT: pctx.root,
974
+ HCC_DB: pctx.dbPath,
975
+ [LAUNCH_FINGERPRINT_ENV]: env[LAUNCH_FINGERPRINT_ENV]
976
+ };
977
+ if (relaunchableProvider) tmuxEnv[PROVIDER_STATE_ENV] = 'starting';
978
+ runTmux(['new-session', '-d', '-s', sessionName, '-c', cwd, ...tmuxEnvironmentArgs(tmuxEnv), launch]);
979
+ createdTmuxSession = true;
980
+ }
981
+
982
+ pane = tmuxPaneInfo(paneTarget).pane;
983
+ for (const oldInfo of oldTmuxTargetsForRebind) {
984
+ const eventDb = connectWebProject(pctx);
985
+ try {
986
+ oldInfo.plan = assertOldTmuxCanRebind(pctx, oldInfo.oldPeer, oldInfo.oldTarget, pane, id, eventDb, {
987
+ force: Boolean(input.providerForce),
988
+ allowedSessionName: oldInfo.allowedSessionName || null
989
+ });
990
+ } finally {
991
+ eventDb.close();
992
+ }
993
+ }
994
+ session = attachTmuxSession({
995
+ ...input,
996
+ id,
997
+ kind,
998
+ role,
999
+ cwd,
1000
+ command,
1001
+ pane,
1002
+ projectCtx: pctx,
1003
+ binding: {
1004
+ ...(input.binding || {}),
1005
+ command: input.binding?.command || command,
1006
+ transport: 'tmux',
1007
+ runtime_session_id: id,
1008
+ runtime_target: pane
1009
+ },
1010
+ providerForce: Boolean(input.providerForce),
1011
+ rebindOldTmux: true,
1012
+ skipProviderRebindCleanup: oldTmuxTargetsForRebind.length > 0,
1013
+ replaceSession: pendingOldSession,
1014
+ force: true
1015
+ });
1016
+ } catch (err) {
1017
+ if (createdTmuxSession) {
1018
+ try { tmuxKillSession(sessionName); } catch {}
1019
+ }
1020
+ const rollback = restoreParkedOldTmuxSessions();
1021
+ // attachTmuxSession removes a failed candidate from the map. Restore the
1022
+ // parked session, whose stream/pollers/clients were deliberately kept
1023
+ // alive until the replacement completed its DB transaction.
1024
+ if (pendingOldSession && rollback.failures.length === 0) {
1025
+ sessions.set(sessionKey(pctx, pendingOldSession.id), pendingOldSession);
1026
+ }
1027
+ if (pendingRestartAudit) {
1028
+ try {
1029
+ const eventDb = connectWebProject(pctx);
1030
+ try {
1031
+ addEvent(eventDb, 'tmux.session.restart_failed', actorPeer, null, auditPayload({
1032
+ actor: actorPeer,
1033
+ target: id,
1034
+ source: auditSource,
1035
+ admin: true,
1036
+ reason: pendingRestartAudit.reason,
1037
+ old_runtime_target: pendingRestartAudit.oldTarget,
1038
+ old_tmux_session: pendingRestartAudit.parkedName,
1039
+ error: err?.message || String(err),
1040
+ rollback_failures: rollback.failures,
1041
+ quarantined_sessions: rollback.quarantined
1042
+ }));
1043
+ } finally {
1044
+ eventDb.close();
1045
+ }
1046
+ } catch {}
1047
+ }
1048
+ pendingOldSession = null;
1049
+ if (rollback.failures.length) {
1050
+ throw new CliError('TMUX_RESTART_ROLLBACK_FAILED', `Session ${id} restart failed and the previous tmux session could not be restored`, {
1051
+ peer: id,
1052
+ original_error: err?.message || String(err),
1053
+ rollback_failures: rollback.failures,
1054
+ quarantined_sessions: rollback.quarantined
1055
+ });
1056
+ }
1057
+ throw err;
1058
+ }
1059
+ if (pendingRestartAudit) {
1060
+ try {
1061
+ const eventDb = connectWebProject(pctx);
1062
+ try {
1063
+ addEvent(eventDb, 'tmux.session.restarted', actorPeer, null, auditPayload({
1064
+ actor: actorPeer,
1065
+ target: id,
1066
+ source: auditSource,
1067
+ admin: true,
1068
+ reason: pendingRestartAudit.reason,
1069
+ old_runtime_target: pendingRestartAudit.oldTarget,
1070
+ old_tmux_session: pendingRestartAudit.parkedName,
1071
+ new_runtime_target: pane
1072
+ }));
1073
+ } finally {
1074
+ eventDb.close();
1075
+ }
1076
+ } catch {}
1077
+ }
1078
+ if (pendingOldSession) {
1079
+ stopTmuxStream(pendingOldSession);
1080
+ if (pendingOldSession.exitPoller) { clearInterval(pendingOldSession.exitPoller); pendingOldSession.exitPoller = null; }
1081
+ pendingOldSession.status = 'detached';
1082
+ pendingOldSession.exitedAt = now();
1083
+ pendingOldSession = null;
1084
+ }
1085
+ for (const oldInfo of oldTmuxTargetsForRebind) {
1086
+ try {
1087
+ const eventDb = connectWebProject(pctx);
1088
+ try {
1089
+ killOldTmuxForRebind(pctx, oldInfo.plan, actorPeer, eventDb);
1090
+ } finally {
1091
+ eventDb.close();
1092
+ }
1093
+ } catch (err) {
1094
+ session.warning = {
1095
+ code: err?.code || 'TMUX_REBIND_CLEANUP_FAILED',
1096
+ message: err?.message || String(err),
1097
+ old_peer: oldInfo.oldPeer,
1098
+ old_runtime_target: oldInfo.oldTarget
1099
+ };
1100
+ }
1101
+ }
1102
+ return session;
1103
+ }
1104
+
1105
+ function restoreTmuxManagedSessions(projectCtx = ctx) {
1106
+ const db = connectWebProject(projectCtx);
1107
+ let rows = [];
1108
+ try {
1109
+ rows = db.prepare(`
1110
+ SELECT p.id, p.kind, p.role, p.status, p.worktree, p.pid,
1111
+ p.pid_start_token, p.pid_command_hash, b.command, b.runtime_target,
1112
+ b.provider, b.provider_session_id, b.provider_session_name,
1113
+ b.resume_mode, b.resume_arg
1114
+ FROM peers p
1115
+ JOIN peer_bindings b ON b.peer = p.id
1116
+ WHERE b.transport = 'tmux' AND b.runtime_target IS NOT NULL
1117
+ ORDER BY p.last_seen_at DESC
1118
+ LIMIT 100
1119
+ `).all();
1120
+ } finally {
1121
+ db.close();
1122
+ }
1123
+ for (const row of rows) {
1124
+ // Do not re-adopt a pane by its stored id without confirming it still
1125
+ // belongs to THIS peer's managed tmux session. tmux pane ids (%N) are
1126
+ // recycled across a tmux server restart, so a stale runtime_target can
1127
+ // point at an unrelated local pane (e.g. the user's editor) and stream its
1128
+ // content + inject keystrokes (sess-01). Apply the same ownership checks
1129
+ // the GC/kill paths already use.
1130
+ const evidence = observePeerEvidence(projectCtx, row, row);
1131
+ if (evidence.state !== 'live') {
1132
+ // Unknown evidence (including parser/permission/root mismatch) is kept
1133
+ // intact. Only jointly confirmed dead tmux/process evidence is stale.
1134
+ if (evidence.state !== 'dead') continue;
1135
+ try {
1136
+ const db2 = connectWebProject(projectCtx);
1137
+ try {
1138
+ db2.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?').run(now(), row.id);
1139
+ } finally { db2.close(); }
1140
+ } catch {}
1141
+ continue;
1142
+ }
1143
+ try {
1144
+ attachTmuxSession({
1145
+ id: row.id,
1146
+ kind: row.kind,
1147
+ role: row.role || 'peer',
1148
+ cwd: row.worktree || projectCtx.root,
1149
+ command: row.command || null,
1150
+ pane: row.runtime_target,
1151
+ projectCtx,
1152
+ force: true,
1153
+ auditActorPeer: 'web-runtime',
1154
+ auditSource: 'runtime',
1155
+ binding: {
1156
+ provider: row.provider,
1157
+ provider_session_id: row.provider_session_id,
1158
+ provider_session_name: row.provider_session_name,
1159
+ resume_mode: row.resume_mode,
1160
+ resume_arg: row.resume_arg,
1161
+ command: row.command,
1162
+ transport: 'tmux',
1163
+ runtime_session_id: row.id,
1164
+ runtime_target: row.runtime_target
1165
+ }
1166
+ });
1167
+ } catch {}
1168
+ }
1169
+ // The fast path above only re-adopts bindings whose runtime_target is still
1170
+ // set. Also sweep by session name so sessions left alive with a cleared
1171
+ // runtime_target (default Stop = detach-without-kill) are recovered.
1172
+ reAdoptOrphanManagedTmuxSessions(projectCtx);
1173
+ }
1174
+
1175
+ // tmux is the source of truth for managed sessions. Re-adopt any live
1176
+ // `hcc-<hash(root)>-*` session this runtime is not already tracking, so a
1177
+ // session left alive by a detach-without-kill or across a runtime restart
1178
+ // stays visible + re-enterable. Returns the set of peerIds that currently own
1179
+ // a live managed tmux session (used by the dead-peer reaper).
1180
+ function reAdoptOrphanManagedTmuxSessions(projectCtx = ctx) {
1181
+ const live = new Set();
1182
+ let names;
1183
+ try {
1184
+ names = tmuxListSessionNames().filter((name) => isProjectManagedTmuxSession(
1185
+ projectCtx,
1186
+ name,
1187
+ tmuxSessionEnvironmentValue(name, 'HCC_ROOT')
1188
+ ));
1189
+ } catch {
1190
+ return live;
1191
+ }
1192
+ if (!names.length) return live;
1193
+
1194
+ // Capture known peers and their stored process evidence so a detached
1195
+ // session is only re-adopted after root, exact name, and identity agree.
1196
+ const bindingByPeer = new Map();
1197
+ try {
1198
+ const db = connectWebProject(projectCtx);
1199
+ try {
1200
+ for (const row of db.prepare(`
1201
+ SELECT p.id, p.status, p.pid, p.pid_start_token, p.pid_command_hash,
1202
+ b.provider, b.provider_session_id, b.provider_session_name,
1203
+ b.resume_mode, b.resume_arg, b.command, b.runtime_target
1204
+ FROM peers p
1205
+ LEFT JOIN peer_bindings b ON b.peer = p.id
1206
+ `).all()) {
1207
+ bindingByPeer.set(row.id, row);
1208
+ }
1209
+ } finally {
1210
+ db.close();
1211
+ }
1212
+ } catch {
1213
+ return live;
1214
+ }
1215
+
1216
+ const tracked = new Set(
1217
+ sessionsForProject(projectCtx)
1218
+ .filter((session) => session.status === 'running' && session.type === 'tmux')
1219
+ .map((session) => session.id)
1220
+ );
1221
+
1222
+ for (const name of names) {
1223
+ // Ownership: the session's HCC_ROOT env must match this project root.
1224
+ const hccRoot = tmuxSessionEnvironmentValue(name, 'HCC_ROOT');
1225
+ if (rootEvidence(projectCtx.root, hccRoot).state !== 'match') continue;
1226
+
1227
+ let info;
1228
+ try { info = tmuxPaneInfo(`${name}:0.0`); } catch { continue; }
1229
+ if (!info || info.dead) continue;
1230
+
1231
+ const candidates = [...bindingByPeer.entries()].filter(([peerId, binding]) => {
1232
+ if (!tmuxManagedSessionNameMatches(projectCtx, name, peerId, hccRoot)) return false;
1233
+ return observePeerEvidence(projectCtx, binding, {
1234
+ transport: 'tmux',
1235
+ runtime_target: info.pane
1236
+ }).state === 'live';
1237
+ });
1238
+ if (candidates.length !== 1) continue;
1239
+ const [[peerId, b]] = candidates;
1240
+ live.add(peerId);
1241
+ if (tracked.has(peerId)) continue;
1242
+ const binding = b ? {
1243
+ provider: b.provider,
1244
+ provider_session_id: b.provider_session_id,
1245
+ provider_session_name: b.provider_session_name,
1246
+ resume_mode: b.resume_mode,
1247
+ resume_arg: b.resume_arg,
1248
+ command: b.command,
1249
+ transport: 'tmux',
1250
+ runtime_session_id: peerId,
1251
+ runtime_target: info.pane
1252
+ } : null;
1253
+
1254
+ try {
1255
+ attachTmuxSession({
1256
+ id: peerId,
1257
+ pane: info.pane,
1258
+ cwd: info.cwd || projectCtx.root,
1259
+ command: b?.command || null,
1260
+ projectCtx,
1261
+ force: false,
1262
+ autoAttach: true,
1263
+ auditActorPeer: 'web-runtime',
1264
+ auditSource: 'runtime',
1265
+ binding
1266
+ });
1267
+ } catch {}
1268
+ }
1269
+ return live;
1270
+ }
1271
+
1272
+ // Mark dead or grace-expired unknown peers as exited so crashed/killed
1273
+ // sessions stop lingering as running. This path only detaches DB state.
1274
+ function reapDeadPeersForProject(projectCtx, liveManagedPeerIds, db) {
1275
+ let rows;
1276
+ try {
1277
+ rows = db.prepare(`
1278
+ SELECT p.id, p.status, p.pid, p.pid_start_token, p.pid_command_hash,
1279
+ p.last_seen_at,
1280
+ b.transport, b.runtime_target
1281
+ FROM peers p
1282
+ LEFT JOIN peer_bindings b ON b.peer = p.id
1283
+ WHERE p.status IN ('running', 'working', 'busy')
1284
+ `).all();
1285
+ } catch {
1286
+ return;
1287
+ }
1288
+ if (!rows.length) return;
1289
+ const observedAt = now();
1290
+ const evidenceByPeer = new Map(rows.map((row) => [
1291
+ row.id,
1292
+ observePeerEvidence(projectCtx, row, row)
1293
+ ]));
1294
+ let clockObservation;
1295
+ try {
1296
+ clockObservation = observeClockSafetyOrThrow(db, {
1297
+ operation: 'ownership',
1298
+ candidates: rows.map((row) => ({
1299
+ boundary: Math.max(0, Number(row.last_seen_at || 0) + UNKNOWN_EVIDENCE_GRACE_SEC),
1300
+ evidence: evidenceByPeer.get(row.id)?.state || 'unknown',
1301
+ owner: row.id
1302
+ })),
1303
+ nowSec: observedAt
1304
+ });
1305
+ } catch {
1306
+ return;
1307
+ }
1308
+ for (const row of rows) {
1309
+ if (liveManagedPeerIds.has(row.id)) continue;
1310
+ const evidence = evidenceByPeer.get(row.id) || { state: 'unknown' };
1311
+ if (!peerEvidenceAllowsReap(evidence, {
1312
+ nowSec: observedAt,
1313
+ lastSeenAt: Number(row.last_seen_at || 0),
1314
+ staleAfterSec: UNKNOWN_EVIDENCE_GRACE_SEC,
1315
+ graceUntil: clockObservation.graceUntil
1316
+ })) continue;
1317
+ try {
1318
+ mutatePeerWithEvidence(db, projectCtx, row.id, (subject, currentEvidence) => {
1319
+ // Preserve last_seen_at (see detachTmuxSession) — only flip status.
1320
+ db.prepare('UPDATE peers SET status = ? WHERE id = ?').run('exited', row.id);
1321
+ db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?').run(now(), row.id);
1322
+ addEvent(db, 'peer.reaped', 'web-runtime', null, auditPayload({
1323
+ actor: 'web-runtime',
1324
+ target: row.id,
1325
+ source: 'runtime',
1326
+ peer: row.id,
1327
+ pid: subject.peer.pid,
1328
+ reason: currentEvidence.reason
1329
+ }));
1330
+ }, {
1331
+ acceptEvidence: (currentEvidence, subject) => peerEvidenceAllowsReap(currentEvidence, {
1332
+ nowSec: now(),
1333
+ lastSeenAt: Number(subject.peer.last_seen_at || 0),
1334
+ staleAfterSec: UNKNOWN_EVIDENCE_GRACE_SEC,
1335
+ graceUntil: readClockGraceUntil(db)
1336
+ }),
1337
+ beforeMutate: ({ subject, evidence: currentEvidence }) => peerEvidenceAllowsReap(currentEvidence, {
1338
+ nowSec: now(),
1339
+ lastSeenAt: Number(subject.peer.last_seen_at || 0),
1340
+ staleAfterSec: UNKNOWN_EVIDENCE_GRACE_SEC,
1341
+ graceUntil: readClockGraceUntil(db)
1342
+ })
1343
+ });
1344
+ } catch {}
1345
+ }
1346
+ }
1347
+
1348
+ return {
1349
+ detachTmuxSession, tmuxSessionNameForPane, detachRuntimeSessionForPane,
1350
+ openClientCountForPane, tmuxClientObservation,
1351
+ observeTmuxDestructiveEvidence, tmuxAttachmentEvidence,
1352
+ assertTmuxDestructiveEvidence, attachmentEvidenceForPane,
1353
+ oldTmuxEventEvidence, addRebindCleanupFailedEvent, oldTmuxRebindTarget,
1354
+ tmuxSessionClientCountForStop, safeTmuxKillPlan, executeTmuxKillPlan,
1355
+ killDbProvenTmuxSession, safeOldTmuxRebindPlan, assertOldTmuxCanRebind,
1356
+ killOldTmuxForRebind, providerSessionBindingMatches, attachTmuxSession,
1357
+ refreshPeerIoHeartbeat, writeSessionInput, scheduleTmuxInputRefresh,
1358
+ resizeSession, startTmuxManagedSession, restoreTmuxManagedSessions,
1359
+ reAdoptOrphanManagedTmuxSessions, reapDeadPeersForProject
1360
+ };
1361
+ }