@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.
- package/CHANGELOG.md +117 -0
- package/README.md +26 -4
- package/README.zh-CN.md +22 -3
- package/bin/hcc.mjs +3 -5622
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +16 -5
- package/docs/commands.zh-CN.md +16 -5
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +53 -5
- package/docs/guide.zh-CN.md +46 -3
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +15 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +139 -15
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +61 -8
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +232 -29
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +143 -7
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- 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
|
+
}
|