@logicseek/hello-cc 0.1.9 → 1.0.1

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 (99) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -3
  3. package/README.zh-CN.md +48 -3
  4. package/bin/hcc.mjs +3 -5656
  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 +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/guide.md +97 -3
  12. package/docs/guide.zh-CN.md +89 -2
  13. package/lib/cli/app.mjs +644 -0
  14. package/lib/cli/commands/coordination.mjs +249 -0
  15. package/lib/cli/commands/doctor.mjs +81 -0
  16. package/lib/cli/commands/gc.mjs +1002 -0
  17. package/lib/cli/commands/hook.mjs +205 -0
  18. package/lib/cli/commands/install.mjs +294 -0
  19. package/lib/cli/commands/lifecycle.mjs +101 -0
  20. package/lib/cli/commands/lock.mjs +197 -0
  21. package/lib/cli/commands/misc.mjs +76 -0
  22. package/lib/cli/commands/msg.mjs +152 -0
  23. package/lib/cli/commands/peer.mjs +249 -0
  24. package/lib/cli/commands/query.mjs +96 -0
  25. package/lib/cli/commands/run.mjs +463 -0
  26. package/lib/cli/commands/task.mjs +405 -0
  27. package/lib/cli/commands/team.mjs +131 -0
  28. package/lib/cli/commands/tmux.mjs +838 -0
  29. package/lib/cli/commands/up.mjs +135 -0
  30. package/lib/cli-args.mjs +16 -2
  31. package/lib/coordination-state.mjs +112 -51
  32. package/lib/core/coordination/automation.mjs +5 -8
  33. package/lib/core/coordination/clock-safety.mjs +323 -0
  34. package/lib/core/coordination/event-retention.mjs +17 -0
  35. package/lib/core/coordination/gc-plan.mjs +614 -0
  36. package/lib/core/coordination/lease-renewal.mjs +102 -0
  37. package/lib/core/coordination/lock-evidence.mjs +77 -0
  38. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  39. package/lib/core/coordination/tasks.mjs +156 -36
  40. package/lib/core/peers/evidence-runtime.mjs +312 -0
  41. package/lib/core/peers/evidence.mjs +103 -0
  42. package/lib/core/peers/hook-owner.mjs +32 -0
  43. package/lib/core/peers/liveness.mjs +44 -4
  44. package/lib/core/peers/peer-helpers.mjs +121 -0
  45. package/lib/core/peers/session.mjs +8 -2
  46. package/lib/core/peers/tmux-safety.mjs +290 -0
  47. package/lib/core/sessions/launch.mjs +13 -1
  48. package/lib/db/connection.mjs +104 -0
  49. package/lib/db/events.mjs +34 -0
  50. package/lib/db/migration-backup.mjs +225 -0
  51. package/lib/db/schema.mjs +102 -5
  52. package/lib/db/stores/peers.mjs +34 -0
  53. package/lib/integrations/peers/identity.mjs +62 -16
  54. package/lib/integrations/shims/script.mjs +111 -9
  55. package/lib/process/identity.mjs +222 -0
  56. package/lib/process/pty-lifecycle.mjs +149 -0
  57. package/lib/runtime/buffer-directory-lease.mjs +48 -0
  58. package/lib/runtime/buffer-evidence.mjs +336 -0
  59. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  60. package/lib/runtime/buffer-gc.mjs +270 -0
  61. package/lib/runtime/client.mjs +63 -7
  62. package/lib/runtime/fatal-shutdown.mjs +54 -0
  63. package/lib/runtime/project-path.mjs +139 -0
  64. package/lib/runtime/projects.mjs +320 -40
  65. package/lib/runtime/state.mjs +227 -27
  66. package/lib/shared/cleanup-error.mjs +54 -0
  67. package/lib/shared/clock-grace.mjs +106 -0
  68. package/lib/shared/errors.mjs +9 -0
  69. package/lib/shared/file-lock.mjs +377 -0
  70. package/lib/shared/json-file.mjs +6 -2
  71. package/lib/shared/redact.mjs +60 -0
  72. package/lib/shared/socket-lock-worker.mjs +250 -0
  73. package/lib/terminal/tmux.mjs +131 -27
  74. package/lib/tmux.mjs +4 -0
  75. package/lib/ui/help.mjs +53 -15
  76. package/lib/web/api-version.mjs +42 -0
  77. package/lib/web/auto-attach.mjs +185 -0
  78. package/lib/web/buffer-gc-runtime.mjs +175 -0
  79. package/lib/web/cookie-auth.mjs +119 -0
  80. package/lib/web/external-sessions.mjs +254 -0
  81. package/lib/web/http-routes.mjs +542 -0
  82. package/lib/web/http.mjs +135 -6
  83. package/lib/web/liveness-reaper.mjs +145 -0
  84. package/lib/web/peer-actions.mjs +132 -23
  85. package/lib/web/project-contexts.mjs +183 -0
  86. package/lib/web/pty-sessions.mjs +203 -0
  87. package/lib/web/runtime-main.mjs +717 -0
  88. package/lib/web/runtime.mjs +91 -36
  89. package/lib/web/session-serialize.mjs +0 -0
  90. package/lib/web/startup.mjs +335 -0
  91. package/lib/web/tls.mjs +365 -0
  92. package/lib/web/tmux-sessions.mjs +1361 -0
  93. package/lib/web/tmux-stream.mjs +166 -0
  94. package/lib/web/ui-template.mjs +152 -20
  95. package/package.json +6 -3
  96. package/scripts/check-free-identifiers.mjs +290 -0
  97. package/scripts/regression.mjs +5404 -273
  98. package/docs/architecture-split-review.md +0 -217
  99. package/docs/layout-split-stack-audit.md +0 -298
@@ -0,0 +1,717 @@
1
+ // The web runtime main entry, extracted whole from bin/hcc.mjs.
2
+ // cmdWeb is one ~3100-line closure: 64 sibling functions share the sessions/
3
+ // projectContexts/buffer state maps. It moves as a unit; every module-scope
4
+ // dependency is injected via createWebRuntime(deps).
5
+
6
+ import fs from 'node:fs';
7
+ import http from 'node:http';
8
+ import https from 'node:https';
9
+ import path from 'node:path';
10
+ import process from 'node:process';
11
+ import { performance } from 'node:perf_hooks';
12
+ import { randomBytes } from 'node:crypto';
13
+ import { spawn } from 'node:child_process';
14
+ import { setTimeout as sleep } from 'node:timers/promises';
15
+ import { URL } from 'node:url';
16
+ import { publicCliFailure } from '../shared/errors.mjs';
17
+ import { CliError } from '../shared/errors.mjs';
18
+ import { tx } from '../db/schema.mjs';
19
+ import { intOpt, parseOpts, required, validateOpts } from '../cli-args.mjs';
20
+ import { packageRoot } from '../cli-runtime.mjs';
21
+ import { createCoordinationState } from '../coordination-state.mjs';
22
+ import { shellQuoteArg } from '../format.mjs';
23
+ import {
24
+ CLOCK_GRACE_SEC,
25
+ classifyClockDrift,
26
+ clockGraceSuppressed,
27
+ readClockGraceUntil
28
+ } from '../shared/clock-grace.mjs';
29
+ import {
30
+ clockSafetyUnavailable,
31
+ observeClockSafetyInTransaction
32
+ } from '../core/coordination/clock-safety.mjs';
33
+ import { peerEvidenceAllowsReap } from '../core/peers/evidence.mjs';
34
+ import {
35
+ conditionalTmuxKill,
36
+ conditionalTmuxRename,
37
+ prepareTmuxRestartBinding,
38
+ rollbackTmuxRestartBinding,
39
+ validateTmuxDestructiveEvidence
40
+ } from '../core/peers/tmux-safety.mjs';
41
+ import {
42
+ LAUNCH_FINGERPRINT_ENV,
43
+ PROVIDER_STATE_ENV,
44
+ WEB_CHILD_ENV,
45
+ childSessionEnv,
46
+ isRelaunchableProviderSession,
47
+ isolatedEnvCommandArgs,
48
+ launchFingerprint,
49
+ providerRestartReason
50
+ } from '../core/sessions/launch.mjs';
51
+ import { providerSessionPeerId } from '../core/peers/session.mjs';
52
+ import { reconcileRunningPeerBindings } from '../core/peers/reconcile.mjs';
53
+ import {
54
+ buildPeerCommand,
55
+ defaultSessionCommand,
56
+ hasResumeOpts,
57
+ inferPeerKind
58
+ } from '../integrations/providers.mjs';
59
+ import { inspectProviderProcess } from '../integrations/peers/processes.mjs';
60
+ import {
61
+ projectRecord,
62
+ readProjectRegistry,
63
+ registerProject,
64
+ registerProjectActivity
65
+ } from '../runtime/projects.mjs';
66
+ import { resolveProjectDatabase } from '../runtime/project-path.mjs';
67
+ import { contextForProject } from '../runtime/paths.mjs';
68
+ import {
69
+ clearRuntime,
70
+ writeGlobalRuntime,
71
+ writeRuntime
72
+ } from '../runtime/state.mjs';
73
+ import { createFatalShutdownController } from '../runtime/fatal-shutdown.mjs';
74
+ import { collectBufferEvidence, externalBufferEvidence, externalBufferOwnerKey, externalBufferSessionIds, readExternalBufferMetadata } from '../runtime/buffer-evidence.mjs';
75
+ import { withBufferDirectoryLease } from '../runtime/buffer-directory-lease.mjs';
76
+ import { applyClockSafeBufferPlan, createBufferGcPlanStore } from '../runtime/buffer-gc-protocol.mjs';
77
+ import { bufferPlanGcCutoffs, planBufferFiles } from '../runtime/buffer-gc.mjs';
78
+ import { detectBranch } from '../project-context.mjs';
79
+ import { normalizeStateResources } from '../ui/state-render.mjs';
80
+ import { positiveIntOpt } from '../task-cli.mjs';
81
+ import {
82
+ expectedWebHost,
83
+ isLoopbackHost,
84
+ listenServer,
85
+ makeWebToken,
86
+ nextSessionId,
87
+ publicRuntimeUrl,
88
+ requestUrl,
89
+ runtimeBaseUrl,
90
+ validateWebTokenOpts
91
+ } from '../web/runtime.mjs';
92
+ import {
93
+ authOk,
94
+ readJsonRequest,
95
+ requestIsSecure,
96
+ requestMatchesProxyOrigin,
97
+ requestOriginMatches,
98
+ sendFile,
99
+ sendJson,
100
+ tokenMatches
101
+ } from '../web/http.mjs';
102
+ import {
103
+ API_VERSION,
104
+ apiVersionUnsupportedBody,
105
+ readHttpApiVersion,
106
+ readWebSocketApiVersion
107
+ } from '../web/api-version.mjs';
108
+ import { ensureSelfSignedCert } from '../web/tls.mjs';
109
+ import * as webUiTemplate from '../web/ui-template.mjs';
110
+ import { createWebPeerActions } from '../web/peer-actions.mjs';
111
+ import { createCookieAuth } from '../web/cookie-auth.mjs';
112
+ import { createProjectContexts } from '../web/project-contexts.mjs';
113
+ import { createExternalSessions } from '../web/external-sessions.mjs';
114
+ import { createLivenessReaper } from '../web/liveness-reaper.mjs';
115
+ import { createBufferGcRuntime } from '../web/buffer-gc-runtime.mjs';
116
+ import { createTmuxSessions } from '../web/tmux-sessions.mjs';
117
+ import { createAutoAttach } from '../web/auto-attach.mjs';
118
+ import { createPtySessions } from '../web/pty-sessions.mjs';
119
+ import { createHttpRoutes } from '../web/http-routes.mjs';
120
+ import { createSessionSerialize } from '../web/session-serialize.mjs';
121
+ import { createTmuxStream } from '../web/tmux-stream.mjs';
122
+ import {
123
+ ensureTmuxAvailable,
124
+ runTmux,
125
+ tmuxCapturePane,
126
+ tmuxEnvironmentArgs,
127
+ tmuxHasSession,
128
+ tmuxKillSession,
129
+ tmuxLaunchFingerprint,
130
+ tmuxListSessionNames,
131
+ tmuxManagedSessionName,
132
+ tmuxManagedSessionNameMatches,
133
+ tmuxPaneInfo,
134
+ tmuxProviderState,
135
+ tmuxSendLiteral,
136
+ tmuxSessionEnvironmentValue,
137
+ tmuxSessionHasClients
138
+ } from '../tmux.mjs';
139
+ import { inspectProcessIdentity, waitForLiveProcessIdentity } from '../process/identity.mjs';
140
+
141
+ export function createWebRuntime(deps) {
142
+ const {
143
+ // constants
144
+ ACTIVE_PEER_TTL, BUFS_DIR_NAME, CLI_NAME, DEFAULT_LOCK_TTL,
145
+ DETECTED_PEER_MAX_AGE, PRODUCT_NAME, UNKNOWN_EVIDENCE_GRACE_SEC, VERSION,
146
+ // db helpers
147
+ connect, addEvent, auditPayload,
148
+ // peer helpers
149
+ touchPeer, upsertPeer, upsertCanonicalPeerBinding,
150
+ // coordination state
151
+ assertTaskOwnerForMutation, claimNextTasksForPeer,
152
+ queryInbox, queryOpenTasks, queryTimelineMessages,
153
+ requestActorPeer, requestSource, sendMessage,
154
+ statusSnapshot, statusSummary, takeOverTaskForPeer,
155
+ webPeerAction,
156
+ // evidence runtime
157
+ canonicalRoot, isProjectManagedTmuxSession, liveProcessIdentity,
158
+ mutatePeerWithEvidence, observeClockSafetyOrThrow, observePeerEvidence,
159
+ peerEvidenceFromDb, providerOwnerEvidenceFromDb, rootEvidence,
160
+ // tmux evidence helpers
161
+ strictTmuxClientObservation, tmuxAttachmentAuthority,
162
+ tmuxPaneForTarget, tmuxSessionCreationToken, tmuxSessionId,
163
+ // gc helpers
164
+ bufferDirectory, runGc,
165
+ // web startup helpers
166
+ assertWebTokenForHost, proxyOriginForOpts, startWebBackground,
167
+ webExposureWarning, webSocketOriginAllowed,
168
+ // local bus
169
+ prepareLocalBus,
170
+ // misc bin-local helpers
171
+ findProviderSessionBinding, helpWeb, latestHookProviderSession,
172
+ now, redactedLogText, renderWebIndex, renderWebLogin,
173
+ sameResolvedPath, sendWebHtml, shellCommand, webErrorStatus
174
+ } = deps;
175
+
176
+ async function cmdWeb(ctx, args, startMeta = {}) {
177
+ if (args[0] === '--help' || args[0] === '-h') return helpWeb();
178
+ if (process.env[WEB_CHILD_ENV] !== '1') return startWebBackground(ctx, args);
179
+ const runtimeIdentity = await waitForLiveProcessIdentity(process.pid, { timeoutMs: 1_000 });
180
+ if (runtimeIdentity.state !== 'live' || !runtimeIdentity.identity) {
181
+ throw new CliError(
182
+ 'RUNTIME_IDENTITY_UNAVAILABLE',
183
+ 'Unable to verify the web runtime process identity; no runtime pointer was published.'
184
+ );
185
+ }
186
+ const processIdentity = runtimeIdentity.identity;
187
+ const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy'] });
188
+ validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy', 'proxy-origin']);
189
+ validateWebTokenOpts(opts);
190
+ const host = expectedWebHost(opts);
191
+ const port = intOpt(opts, 'port', 8787);
192
+ const token = makeWebToken(opts);
193
+ assertWebTokenForHost(host, Boolean(token));
194
+ if (!isLoopbackHost(host)) console.error(redactedLogText(webExposureWarning(host, port) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
195
+ const useTls = Boolean(opts.tls);
196
+ const trustProxy = Boolean(opts['trust-proxy']);
197
+ const proxyOrigin = proxyOriginForOpts(opts);
198
+ const tlsCredentials = useTls ? ensureSelfSignedCert([host]) : null;
199
+ // Browser sessions: a token printed in the URL is exchanged once for an
200
+ // HttpOnly cookie so the token stops travelling in every fetch/WS URL
201
+ // (net-02). The cookie carries an opaque session id (not the token); it is
202
+ // meaningless outside this runtime and is lost on restart.
203
+ const DEFAULT_WEB_SESSION_TTL_SEC = 30 * 24 * 60 * 60;
204
+ const regressionWebSessionTtlRaw = process.env.HCC_REGRESSION_WEB_SESSION_TTL_SEC || '';
205
+ const regressionWebSessionTtl = /^\d+$/.test(regressionWebSessionTtlRaw)
206
+ ? Number.parseInt(regressionWebSessionTtlRaw, 10)
207
+ : 0;
208
+ const WEB_SESSION_TTL_SEC = process.env.HCC_REGRESSION_TEST === '1' &&
209
+ regressionWebSessionTtl >= 1 && regressionWebSessionTtl <= 60
210
+ ? regressionWebSessionTtl
211
+ : DEFAULT_WEB_SESSION_TTL_SEC;
212
+ const MAX_WEB_SESSIONS = 256;
213
+ const {
214
+ webSessions,
215
+ parseCookieSid, closeWebSession, pruneWebSessions,
216
+ issueSession, sessionCookieHeader, expiredSessionCookieHeader,
217
+ cookieSessionRecord, cookieSessionOk, cookieSocketValid, webAuthMode
218
+ } = createCookieAuth({
219
+ now, ttlSec: WEB_SESSION_TTL_SEC, maxSessions: MAX_WEB_SESSIONS,
220
+ requestIsSecure, trustProxy, proxyOrigin, authOk, token
221
+ });
222
+ const webSessionPruner = setInterval(pruneWebSessions, 60000);
223
+ webSessionPruner.unref?.();
224
+ ensureTmuxAvailable({ autoInstall: false });
225
+ const ptyModule = await import('node-pty');
226
+ const { WebSocketServer } = await import('ws');
227
+ const pty = ptyModule.default || ptyModule;
228
+ const sessions = new Map();
229
+
230
+ const {
231
+ sessionKey, sessionsForProject,
232
+ resolveSessionPeerId, sessionBindingForSerialize,
233
+ serializeBindingSummary, serializeSession,
234
+ broadcast, hasOpenClients, closeSessionClients
235
+ } = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath });
236
+ // refreshPeerIoHeartbeat comes from createTmuxSessions below; wrap it so the
237
+ // reference resolves at call time instead of at this forward-passing site.
238
+ const lazyRefreshPeerIoHeartbeat = (session) => refreshPeerIoHeartbeat(session);
239
+ const { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream } = createTmuxStream({ broadcast, now, refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat, bufferDirectory, withBufferDirectoryLease, shellQuoteArg, ctx });
240
+ const prepared = await prepareLocalBus(ctx, opts);
241
+
242
+ const {
243
+ projectContexts,
244
+ newSessionActionToken, rememberProject, knownProjects,
245
+ resolveWebProjectContext, connectWebProject, projectFromRequest,
246
+ getSession, readActionToken, resolveWebActionSession,
247
+ knownPeerIds, nextProjectSessionId
248
+ } = createProjectContexts({
249
+ ctx, sessions,
250
+ sessionKey, sessionsForProject, resolveSessionPeerId,
251
+ connect, now, addEvent, tx, touchPeer, upsertPeer, detectBranch,
252
+ ACTIVE_PEER_TTL, CLI_NAME, DEFAULT_LOCK_TTL,
253
+ queryInbox, queryOpenTasks, queryTimelineMessages,
254
+ observePeerEvidence, peerEvidenceFromDb,
255
+ observeClockSafetyInTransaction,
256
+ assertTaskOwnerForMutation, claimNextTasksForPeer, takeOverTaskForPeer,
257
+ positiveIntOpt, sameResolvedPath
258
+ });
259
+
260
+ const {
261
+ statusSnapshot: webStatusSnapshot,
262
+ statusSummary: webStatusSummary
263
+ } = createCoordinationState({
264
+ activePeerTtl: ACTIVE_PEER_TTL,
265
+ cliName: CLI_NAME,
266
+ connect: connectWebProject,
267
+ defaultLockTtl: DEFAULT_LOCK_TTL,
268
+ now,
269
+ queryInbox,
270
+ queryOpenTasks,
271
+ queryTimelineMessages,
272
+ observePeerEvidence
273
+ });
274
+ const {
275
+ webPeerAction: webPeerActionForProject
276
+ } = createWebPeerActions({
277
+ activePeerTtl: ACTIVE_PEER_TTL,
278
+ addEvent,
279
+ assertTaskOwnerForMutation,
280
+ claimNextTasksForPeer,
281
+ connect: connectWebProject,
282
+ defaultLockTtl: DEFAULT_LOCK_TTL,
283
+ detectBranch,
284
+ now,
285
+ observeClockSafetyInTransaction,
286
+ observePeerEvidence,
287
+ positiveIntOpt,
288
+ peerEvidenceFromDb,
289
+ queryInbox,
290
+ statusSnapshot: webStatusSnapshot,
291
+ statusSummary: webStatusSummary,
292
+ takeOverTaskForPeer,
293
+ touchPeer,
294
+ tx,
295
+ upsertPeer
296
+ });
297
+
298
+
299
+ rememberProject(ctx);
300
+ for (const project of readProjectRegistry()) {
301
+ projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
302
+ }
303
+
304
+
305
+
306
+ const {
307
+ reaperPoller, runtimeProjectContexts, runClockAwareReaper, detectClockJump
308
+ } = createLivenessReaper({
309
+ ctx, projectContexts, sessions,
310
+ sessionsForProject, connectWebProject,
311
+ now, addEvent,
312
+ peerEvidenceFromDb, mutatePeerWithEvidence, observeClockSafetyOrThrow,
313
+ UNKNOWN_EVIDENCE_GRACE_SEC, redactedLogText, sameResolvedPath
314
+ });
315
+
316
+ const {
317
+ gcPoller, runAutoGc,
318
+ collectRuntimeBufferEvidence, prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
319
+ runningBufferPathSnapshot, bufferGcPlanStore
320
+ } = createBufferGcRuntime({
321
+ sessions,
322
+ connectWebProject, runtimeProjectContexts,
323
+ now, runGc, bufferDirectory,
324
+ canonicalRoot, observePeerEvidence, redactedLogText, sameResolvedPath
325
+ });
326
+
327
+ const {
328
+ bufsWatchers, externalScanPoller, bufsWatcherSyncPoller,
329
+ scanExternalSessions, adoptExternalSession, removeExternalBufferFiles
330
+ } = createExternalSessions({
331
+ ctx, sessions,
332
+ sessionKey, broadcast,
333
+ now, tx, connectWebProject,
334
+ bufferDirectory, runtimeProjectContexts,
335
+ refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat, redactedLogText, BUFS_DIR_NAME
336
+ });
337
+
338
+ const {
339
+ detachTmuxSession, tmuxSessionNameForPane, detachRuntimeSessionForPane,
340
+ openClientCountForPane, tmuxClientObservation,
341
+ observeTmuxDestructiveEvidence, tmuxAttachmentEvidence,
342
+ assertTmuxDestructiveEvidence, attachmentEvidenceForPane,
343
+ oldTmuxEventEvidence, addRebindCleanupFailedEvent, oldTmuxRebindTarget,
344
+ tmuxSessionClientCountForStop, safeTmuxKillPlan, executeTmuxKillPlan,
345
+ killDbProvenTmuxSession, safeOldTmuxRebindPlan, assertOldTmuxCanRebind,
346
+ killOldTmuxForRebind, providerSessionBindingMatches, attachTmuxSession,
347
+ refreshPeerIoHeartbeat, writeSessionInput, scheduleTmuxInputRefresh,
348
+ resizeSession, startTmuxManagedSession, restoreTmuxManagedSessions,
349
+ reAdoptOrphanManagedTmuxSessions, reapDeadPeersForProject
350
+ } = createTmuxSessions({
351
+ ctx, sessions,
352
+ broadcast, closeSessionClients, hasOpenClients,
353
+ sessionKey, sessionsForProject, resolveSessionPeerId,
354
+ startTmuxStream, stopTmuxStream, refreshTmuxSnapshot,
355
+ connectWebProject, nextProjectSessionId, getSession,
356
+ runtimeProjectContexts,
357
+ scanExternalSessions, adoptExternalSession, removeExternalBufferFiles,
358
+ now, addEvent, auditPayload, tx,
359
+ canonicalRoot, liveProcessIdentity, rootEvidence,
360
+ observePeerEvidence, mutatePeerWithEvidence, observeClockSafetyOrThrow,
361
+ strictTmuxClientObservation, tmuxAttachmentAuthority,
362
+ tmuxPaneForTarget, tmuxSessionCreationToken, tmuxSessionId,
363
+ isProjectManagedTmuxSession, redactedLogText, bufferDirectory,
364
+ UNKNOWN_EVIDENCE_GRACE_SEC, BUFS_DIR_NAME, CLI_NAME,
365
+ requestActorPeer, requestSource,
366
+ upsertPeer, upsertCanonicalPeerBinding,
367
+ findProviderSessionBinding, providerOwnerEvidenceFromDb,
368
+ shellCommand, detectBranch
369
+ });
370
+ const {
371
+ autoAttachPoller, listTmuxPanesOnce, attachedTmuxState,
372
+ reconcileRunningBindings, scanAndAttachDetectedPeers
373
+ } = createAutoAttach({
374
+ ctx, sessions,
375
+ connectWebProject, now, addEvent, ACTIVE_PEER_TTL,
376
+ reconcileRunningPeerBindings,
377
+ redactedLogText, sessionsForProject,
378
+ attachTmuxSession,
379
+ latestHookProviderSession,
380
+ resolveSessionPeerId,
381
+ reAdoptOrphanManagedTmuxSessions, reapDeadPeersForProject
382
+ });
383
+ scanAndAttachDetectedPeers();
384
+
385
+ const {
386
+ startPtySession, webSessionBuildOptions, webSessionPeerId,
387
+ normalizeWebSessionInput, startSession
388
+ } = createPtySessions({
389
+ pty, ctx, sessions,
390
+ broadcast, closeSessionClients, sessionKey,
391
+ connectWebProject, nextProjectSessionId,
392
+ startTmuxManagedSession,
393
+ now, addEvent, auditPayload, refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat,
394
+ upsertPeer, upsertCanonicalPeerBinding,
395
+ requestActorPeer, requestSource
396
+ });
397
+
398
+ const restoredTmuxDbs = new Set();
399
+ for (const projectCtx of runtimeProjectContexts()) {
400
+ const dbKey = path.resolve(projectCtx.dbPath);
401
+ if (restoredTmuxDbs.has(dbKey)) continue;
402
+ restoredTmuxDbs.add(dbKey);
403
+ restoreTmuxManagedSessions(projectCtx);
404
+ reconcileRunningBindings(projectCtx);
405
+ }
406
+ runAutoGc();
407
+
408
+ const { handleWebRequest } = createHttpRoutes({
409
+ ctx, sessions,
410
+ token, host, port, useTls, trustProxy, proxyOrigin,
411
+ broadcast, serializeSession, sessionsForProject, resolveSessionPeerId,
412
+ parseCookieSid, closeWebSession, issueSession, sessionCookieHeader,
413
+ expiredSessionCookieHeader, cookieSessionOk, webAuthMode,
414
+ rememberProject, knownProjects, resolveWebProjectContext,
415
+ connectWebProject, projectFromRequest, getSession, resolveWebActionSession,
416
+ prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
417
+ detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
418
+ killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
419
+ webStatusSnapshot, webPeerActionForProject,
420
+ now, addEvent, auditPayload, sendMessage,
421
+ getProcessIdentity: () => processIdentity,
422
+ getActualPort: () => actualPort,
423
+ shutdown, tlsCredentials,
424
+ renderWebIndex, renderWebLogin, sendWebHtml, webErrorStatus,
425
+ ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
426
+ });
427
+
428
+ const server = useTls
429
+ ? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, handleWebRequest)
430
+ : http.createServer(handleWebRequest);
431
+
432
+ const wss = new WebSocketServer({ noServer: true });
433
+ server.on('upgrade', (req, socket, head) => {
434
+ try {
435
+ const url = requestUrl(req);
436
+ if (!readWebSocketApiVersion(url).ok) {
437
+ const body = JSON.stringify(apiVersionUnsupportedBody());
438
+ socket.end([
439
+ 'HTTP/1.1 426 Upgrade Required',
440
+ 'Content-Type: application/json; charset=utf-8',
441
+ `Content-Length: ${Buffer.byteLength(body)}`,
442
+ 'Connection: close',
443
+ '',
444
+ body
445
+ ].join('\r\n'));
446
+ return;
447
+ }
448
+ const upgradeAuthMode = webAuthMode(url, req);
449
+ if (!upgradeAuthMode) {
450
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
451
+ socket.destroy();
452
+ return;
453
+ }
454
+ const cookieAuth = upgradeAuthMode === 'cookie' ? cookieSessionRecord(req) : null;
455
+ if (upgradeAuthMode === 'cookie' && !cookieAuth) {
456
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
457
+ socket.destroy();
458
+ return;
459
+ }
460
+ if (!webSocketOriginAllowed(req, { trustProxy, proxyOrigin })) {
461
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
462
+ socket.destroy();
463
+ return;
464
+ }
465
+ const match = url.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
466
+ if (!match) {
467
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
468
+ socket.destroy();
469
+ return;
470
+ }
471
+ const reqCtx = projectFromRequest(req, url);
472
+ const id = decodeURIComponent(match[1]);
473
+ const lookupDb = connectWebProject(reqCtx);
474
+ let session;
475
+ try {
476
+ session = getSession(reqCtx, id, lookupDb);
477
+ } finally {
478
+ lookupDb.close();
479
+ }
480
+ if (!session) {
481
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
482
+ socket.destroy();
483
+ return;
484
+ }
485
+ // ws-5: cap concurrent WS clients per session so the action_token (delivered
486
+ // via the snapshot frame) cannot be harvested by unlimited connections.
487
+ if (session.clients.size >= 4) {
488
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
489
+ socket.destroy();
490
+ return;
491
+ }
492
+ wss.handleUpgrade(req, socket, head, (ws) => {
493
+ const connectionActionToken = newSessionActionToken();
494
+ ws.hccCookieAuth = cookieAuth;
495
+ session.clients.add(ws);
496
+ if (cookieAuth) cookieAuth.session.sockets.add(ws);
497
+ if (!cookieSocketValid(ws)) {
498
+ session.clients.delete(ws);
499
+ return;
500
+ }
501
+ session.actionTokens ||= new Set();
502
+ session.actionTokenSockets ||= new Map();
503
+ session.actionTokens.add(connectionActionToken);
504
+ session.actionTokenSockets.set(connectionActionToken, ws);
505
+ ws.send(JSON.stringify({ type: 'snapshot', data: refreshTmuxSnapshot(session), action_token: connectionActionToken }));
506
+ ws.on('message', (raw) => {
507
+ try {
508
+ if (!cookieSocketValid(ws)) return;
509
+ const msg = JSON.parse(String(raw));
510
+ if (msg.type === 'input' && session.status === 'running') {
511
+ if (!tokenMatches(msg.action_token, connectionActionToken)) return;
512
+ const data = String(msg.data || '');
513
+ writeSessionInput(session, data);
514
+ } else if (msg.type === 'resize' && session.status === 'running') {
515
+ if (!tokenMatches(msg.action_token, connectionActionToken)) return;
516
+ const cols = Math.max(20, Number.parseInt(msg.cols || 100, 10));
517
+ const rows = Math.max(8, Number.parseInt(msg.rows || 30, 10));
518
+ resizeSession(session, cols, rows);
519
+ scheduleTmuxReplace(session);
520
+ }
521
+ } catch {
522
+ // Ignore malformed terminal frames.
523
+ }
524
+ });
525
+ ws.on('close', () => {
526
+ session.clients.delete(ws);
527
+ session.actionTokens.delete(connectionActionToken);
528
+ session.actionTokenSockets.delete(connectionActionToken);
529
+ if (cookieAuth) cookieAuth.session.sockets.delete(ws);
530
+ });
531
+ });
532
+ } catch (err) {
533
+ console.error(redactedLogText(`[${new Date().toISOString()}] ws upgrade failed: ${err?.message || err}`));
534
+ try { socket.destroy(); } catch {}
535
+ }
536
+ });
537
+
538
+ let cleanupPromise = null;
539
+ let shutdownExitCode = 0;
540
+ async function performRuntimeCleanup() {
541
+ clearRuntime(ctx);
542
+ // rob-07: clear this runtime's per-project pointers for every registered
543
+ // project, not just the primary ctx, so sibling projects don't keep
544
+ // pointing at a now-dead runtime.
545
+ try {
546
+ for (const project of readProjectRegistry()) {
547
+ clearRuntime({ root: project.root }, process.pid);
548
+ }
549
+ } catch {}
550
+ // sess-02: mark managed (tmux/pty) sessions detached in their own project
551
+ // DB and clear their runtime_target, so a restart does not consume stale
552
+ // bindings. Best-effort; never block shutdown.
553
+ try {
554
+ const byRoot = new Map();
555
+ for (const s of sessions.values()) {
556
+ if (s.status !== 'running' || (s.type !== 'tmux' && s.type !== 'pty')) continue;
557
+ const peerId = s.peerId || s.id;
558
+ if (!peerId) continue;
559
+ const root = path.resolve(s.ctx?.root || s.root || ctx.root);
560
+ if (!byRoot.has(root)) byRoot.set(root, new Set());
561
+ byRoot.get(root).add(peerId);
562
+ }
563
+ const detachedAt = now();
564
+ for (const [root, idSet] of byRoot) {
565
+ const peerIds = [...idSet];
566
+ if (!peerIds.length) continue;
567
+ const placeholders = peerIds.map(() => '?').join(',');
568
+ const cleanupDb = connectWebProject(contextForProject(root, null, { json: ctx.json }));
569
+ try {
570
+ cleanupDb.prepare(`UPDATE peers SET status = 'detached' WHERE id IN (${placeholders}) AND status IN ('running','working','busy')`).run(...peerIds);
571
+ cleanupDb.prepare(`UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer IN (${placeholders})`).run(detachedAt, ...peerIds);
572
+ } finally {
573
+ cleanupDb.close();
574
+ }
575
+ }
576
+ } catch {}
577
+ clearInterval(externalScanPoller);
578
+ clearInterval(autoAttachPoller);
579
+ clearInterval(reaperPoller);
580
+ clearInterval(gcPoller);
581
+ clearInterval(webSessionPruner);
582
+ try { for (const w of bufsWatchers.values()) w.close(); } catch {}
583
+ clearInterval(bufsWatcherSyncPoller);
584
+ for (const session of sessions.values()) {
585
+ closeSessionClients(session);
586
+ if (session.status !== 'running') continue;
587
+ if (session.type === 'external') {
588
+ try { if (session.outputFd) fs.closeSync(session.outputFd); } catch {}
589
+ try { if (session.outputPoller) clearInterval(session.outputPoller); } catch {}
590
+ try { if (session.exitPoller) clearInterval(session.exitPoller); } catch {}
591
+ } else if (session.type === 'tmux') {
592
+ try { stopTmuxStream(session); } catch {}
593
+ try { if (session.exitPoller) clearInterval(session.exitPoller); } catch {}
594
+ } else {
595
+ try { session.pty.kill(); } catch {}
596
+ }
597
+ }
598
+ try { wss.close(); } catch {}
599
+ await new Promise((resolve) => {
600
+ const terminateClients = setTimeout(() => {
601
+ for (const session of sessions.values()) {
602
+ for (const client of [...(session.clients || [])]) {
603
+ try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
604
+ }
605
+ }
606
+ try { server.closeAllConnections?.(); } catch {}
607
+ }, 250);
608
+ const forceClose = setTimeout(() => {
609
+ try { server.closeAllConnections?.(); } catch {}
610
+ resolve();
611
+ }, 1500);
612
+ try {
613
+ server.close(() => {
614
+ clearTimeout(terminateClients);
615
+ clearTimeout(forceClose);
616
+ resolve();
617
+ });
618
+ try { server.closeIdleConnections?.(); } catch {}
619
+ } catch {
620
+ clearTimeout(terminateClients);
621
+ clearTimeout(forceClose);
622
+ resolve();
623
+ }
624
+ });
625
+ }
626
+ function cleanupRuntime() {
627
+ if (!cleanupPromise) cleanupPromise = performRuntimeCleanup();
628
+ return cleanupPromise;
629
+ }
630
+ function shutdown() {
631
+ void cleanupRuntime().then(
632
+ () => process.exit(shutdownExitCode),
633
+ (error) => {
634
+ console.error(redactedLogText(`[${new Date().toISOString()}] runtime cleanup failed: ${error?.stack || error}`));
635
+ process.exit(1);
636
+ }
637
+ );
638
+ }
639
+ process.once('SIGINT', shutdown);
640
+ process.once('SIGTERM', shutdown);
641
+ // Best-effort backstop for exit paths that bypass shutdown() (process.exit,
642
+ // uncaught exceptions): synchronously kill pty-backed children so they are not
643
+ // orphaned. Cannot run on SIGKILL — the dead-peer reaper repairs DB state on
644
+ // the next start. tmux sessions are intentionally left alive.
645
+ process.on('exit', () => {
646
+ for (const session of sessions.values()) {
647
+ if (session.type !== 'tmux' && session.type !== 'external' && session.pty) {
648
+ try { session.pty.kill(); } catch {}
649
+ }
650
+ }
651
+ });
652
+
653
+ const fatalController = createFatalShutdownController({
654
+ cleanup: () => {
655
+ shutdownExitCode = 1;
656
+ return cleanupRuntime();
657
+ },
658
+ exit: (code) => process.exit(code),
659
+ forceExit: (code) => process.exit(code),
660
+ log: (entry) => console.error(redactedLogText({
661
+ timestamp: new Date().toISOString(),
662
+ ...entry
663
+ }))
664
+ });
665
+ process.on('uncaughtException', (err) => {
666
+ void fatalController.fatal(err);
667
+ });
668
+ process.on('unhandledRejection', (reason) => {
669
+ void fatalController.fatal(reason);
670
+ });
671
+
672
+ const actualPort = await listenServer(server, host, port, opts.port === undefined);
673
+ const runtime = {
674
+ product: PRODUCT_NAME,
675
+ version: VERSION,
676
+ api_version: API_VERSION,
677
+ pid: process.pid,
678
+ ...(processIdentity ? { process_identity: processIdentity } : {}),
679
+ root: ctx.root,
680
+ db: ctx.dbPath,
681
+ host,
682
+ port: actualPort,
683
+ base_url: runtimeBaseUrl(host, actualPort, useTls),
684
+ token,
685
+ tls: useTls,
686
+ trust_proxy: trustProxy,
687
+ proxy_origin: proxyOrigin,
688
+ tls_cert: useTls ? tlsCredentials.cert : undefined,
689
+ started_at: now()
690
+ };
691
+ const runtimeFile = writeRuntime(ctx, runtime);
692
+ writeGlobalRuntime(runtime);
693
+ registerProject(ctx);
694
+ const db = connectWebProject(ctx);
695
+ try {
696
+ addEvent(db, startMeta.eventType || 'web.started', 'human', null, auditPayload({
697
+ actor: 'human',
698
+ source: 'cli',
699
+ root: ctx.root,
700
+ db: ctx.dbPath,
701
+ host,
702
+ port: actualPort,
703
+ requested_port: port,
704
+ guidance: startMeta.guidance || prepared.guidance || null,
705
+ runtime: runtimeFile
706
+ }));
707
+ } finally {
708
+ db.close();
709
+ }
710
+ console.log(redactedLogText(`${PRODUCT_NAME} web listening on ${host}:${actualPort}`));
711
+ console.log(redactedLogText(`project: ${ctx.root}`));
712
+ console.log(redactedLogText(`database: ${ctx.dbPath}`));
713
+ console.log(redactedLogText(`open: ${publicRuntimeUrl(runtime, ctx.root)}`));
714
+ }
715
+
716
+ return { cmdWeb };
717
+ }