@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3

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 (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -59,18 +59,18 @@ import {
59
59
  import { inspectProviderProcess } from '../integrations/peers/processes.mjs';
60
60
  import {
61
61
  projectRecord,
62
- readProjectRegistry,
63
62
  registerProject,
64
63
  registerProjectActivity
65
64
  } from '../runtime/projects.mjs';
66
65
  import { resolveProjectDatabase } from '../runtime/project-path.mjs';
67
66
  import { contextForProject } from '../runtime/paths.mjs';
68
67
  import {
69
- clearRuntime,
70
68
  writeGlobalRuntime,
71
69
  writeRuntime
72
70
  } from '../runtime/state.mjs';
73
71
  import { createFatalShutdownController } from '../runtime/fatal-shutdown.mjs';
72
+ import { cleanupRuntimeState } from './shutdown-state.mjs';
73
+ import { createShutdownDiagnostics } from './shutdown-diagnostics.mjs';
74
74
  import { collectBufferEvidence, externalBufferEvidence, externalBufferOwnerKey, externalBufferSessionIds, readExternalBufferMetadata } from '../runtime/buffer-evidence.mjs';
75
75
  import { withBufferDirectoryLease } from '../runtime/buffer-directory-lease.mjs';
76
76
  import { applyClockSafeBufferPlan, createBufferGcPlanStore } from '../runtime/buffer-gc-protocol.mjs';
@@ -109,7 +109,7 @@ import {
109
109
  import { ensureSelfSignedCert } from '../web/tls.mjs';
110
110
  import * as webUiTemplate from '../web/ui-template.mjs';
111
111
  import { createWebPeerActions } from '../web/peer-actions.mjs';
112
- import { createCookieAuth } from '../web/cookie-auth.mjs';
112
+ import { createCookieAuth, createCookieNameForRequest } from '../web/cookie-auth.mjs';
113
113
  import { createControlLease } from './control-lease.mjs';
114
114
  import { createCodexSessions } from './codex-sessions.mjs';
115
115
  import { createTaskResults } from './task-results.mjs';
@@ -190,6 +190,9 @@ async function cmdWeb(ctx, args, startMeta = {}) {
190
190
  );
191
191
  }
192
192
  const processIdentity = runtimeIdentity.identity;
193
+ const shutdownDiagnostic = createShutdownDiagnostics({ enabled: process.env.HCC_SHUTDOWN_DIAGNOSTICS === '1' });
194
+ let stopping = false;
195
+ const isStopping = () => stopping;
193
196
  const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy'] });
194
197
  validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy', 'proxy-origin']);
195
198
  validateWebTokenOpts(opts);
@@ -223,6 +226,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
223
226
  cookieSessionRecord, cookieSessionOk, cookieSocketValid, webAuthMode
224
227
  } = createCookieAuth({
225
228
  now, ttlSec: WEB_SESSION_TTL_SEC, maxSessions: MAX_WEB_SESSIONS,
229
+ cookieNameForRequest: createCookieNameForRequest({ useTls, trustProxy, proxyOrigin }),
226
230
  requestIsSecure, trustProxy, proxyOrigin, authOk, token
227
231
  });
228
232
  const webSessionPruner = setInterval(pruneWebSessions, 60000);
@@ -238,7 +242,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
238
242
  sessionKey, sessionsForProject,
239
243
  resolveSessionPeerId, sessionBindingForSerialize,
240
244
  serializeBindingSummary, serializeSession,
241
- broadcast, hasOpenClients, closeSessionClients: closeSerializedSessionClients
245
+ broadcast, sendStateSnapshot, hasOpenClients, closeSessionClients: closeSerializedSessionClients
242
246
  } = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath,
243
247
  localClientObservation: tmuxClientSnapshot.observe
244
248
  });
@@ -254,6 +258,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
254
258
  closeSerializedSessionClients(session);
255
259
  }
256
260
  function assertSessionControl(session, actionToken, epoch) {
261
+ const socket = session.actionTokenSockets?.get(actionToken);
262
+ if (socket && (socket.hccStateClosing || socket.readyState !== 1)) throw new CliError('UNKNOWN_CONTROL_CLIENT', 'Terminal client is reconnecting');
257
263
  try { return controlLease.assertControl(session, actionToken, epoch); }
258
264
  catch (error) { throw new CliError(error.code || 'CONTROL_REQUIRED', error.message); }
259
265
  }
@@ -268,12 +274,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
268
274
  // refreshPeerIoHeartbeat comes from createTmuxSessions below; wrap it so the
269
275
  // reference resolves at call time instead of at this forward-passing site.
270
276
  const lazyRefreshPeerIoHeartbeat = (session) => refreshPeerIoHeartbeat(session);
271
- const { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream } = createTmuxStream({ broadcast, now, refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat, bufferDirectory, withBufferDirectoryLease, shellQuoteArg, ctx });
277
+ const { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream } = createTmuxStream({ broadcast, now, refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat, bufferDirectory, withBufferDirectoryLease, shellQuoteArg, ctx, isStopping });
272
278
  const prepared = await prepareLocalBus(ctx, opts);
273
279
 
274
- const {
275
- projectContexts,
276
- newSessionActionToken, rememberProject, knownProjects,
280
+ const {
281
+ projectContexts, releaseProjectContexts, dropIdleProjectContextDescriptors,
282
+ retainProjectContextDescriptor,
283
+ newSessionActionToken, rememberProject, knownProjects, selectedProjectIdentity,
277
284
  resolveWebProjectContext, connectWebProject, projectFromRequest,
278
285
  getSession, readActionToken, resolveWebActionSession,
279
286
  knownPeerIds, nextProjectSessionId
@@ -328,17 +335,17 @@ const {
328
335
  });
329
336
 
330
337
 
331
- rememberProject(ctx);
332
- for (const project of readProjectRegistry()) {
333
- projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
334
- }
338
+ // Background discovery must share the retained directory identity used by
339
+ // HTTP requests, so recovered sessions remain visible under that project.
340
+ ctx = rememberProject(ctx);
341
+ knownProjects();
335
342
 
336
343
 
337
344
 
338
345
  const {
339
346
  reaperPoller, runtimeProjectContexts, runClockAwareReaper, detectClockJump
340
347
  } = createLivenessReaper({
341
- ctx, projectContexts, sessions,
348
+ ctx, projectContexts, sessions, isStopping,
342
349
  sessionsForProject, connectWebProject,
343
350
  now, addEvent,
344
351
  peerEvidenceFromDb, mutatePeerWithEvidence, observeClockSafetyOrThrow,
@@ -350,7 +357,7 @@ const {
350
357
  collectRuntimeBufferEvidence, prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
351
358
  runningBufferPathSnapshot, bufferGcPlanStore
352
359
  } = createBufferGcRuntime({
353
- sessions,
360
+ sessions, isStopping,
354
361
  connectWebProject, runtimeProjectContexts,
355
362
  now, runGc, bufferDirectory,
356
363
  canonicalRoot, observePeerEvidence, redactedLogText, sameResolvedPath
@@ -358,9 +365,9 @@ const {
358
365
 
359
366
  const {
360
367
  bufsWatchers, externalScanPoller, bufsWatcherSyncPoller,
361
- scanExternalSessions, adoptExternalSession, removeExternalBufferFiles
368
+ scanExternalSessions, adoptExternalSession, removeExternalBufferFiles, stopExternalDiscovery
362
369
  } = createExternalSessions({
363
- ctx, sessions,
370
+ ctx, sessions, isStopping,
364
371
  sessionKey, broadcast,
365
372
  now, tx, connectWebProject,
366
373
  bufferDirectory, runtimeProjectContexts,
@@ -380,7 +387,7 @@ const {
380
387
  resizeSession, startTmuxManagedSession, restoreTmuxManagedSessions,
381
388
  reAdoptOrphanManagedTmuxSessions, reapDeadPeersForProject
382
389
  } = createTmuxSessions({
383
- ctx, sessions,
390
+ ctx, sessions, isStopping,
384
391
  broadcast, closeSessionClients, hasOpenClients,
385
392
  sessionKey, sessionsForProject, resolveSessionPeerId,
386
393
  startTmuxStream, stopTmuxStream, refreshTmuxSnapshot,
@@ -403,7 +410,7 @@ const {
403
410
  autoAttachPoller, listTmuxPanesOnce, attachedTmuxState,
404
411
  reconcileRunningBindings, scanAndAttachDetectedPeers
405
412
  } = createAutoAttach({
406
- ctx, sessions,
413
+ ctx, sessions, isStopping,
407
414
  connectWebProject, now, addEvent, ACTIVE_PEER_TTL,
408
415
  reconcileRunningPeerBindings,
409
416
  redactedLogText, sessionsForProject,
@@ -418,7 +425,7 @@ const {
418
425
  startPtySession, webSessionBuildOptions, webSessionPeerId,
419
426
  normalizeWebSessionInput, startSession
420
427
  } = createPtySessions({
421
- pty, ctx, sessions,
428
+ pty, ctx, sessions, isStopping,
422
429
  broadcast, closeSessionClients, sessionKey,
423
430
  connectWebProject, nextProjectSessionId,
424
431
  startTmuxManagedSession,
@@ -431,37 +438,47 @@ const {
431
438
  });
432
439
  const { startCodexSession, codexAction, stopCodexSession,
433
440
  listCodexThreads, readCodexThread, forkCodexThread } = createCodexSessions({
434
- sessions, sessionKey, nextProjectSessionId, connectWebProject, upsertPeer,
441
+ sessions, sessionKey, nextProjectSessionId, connectWebProject, upsertPeer, isStopping,
435
442
  upsertCanonicalPeerBinding, observePeerEvidence,
436
- broadcast: (session, payload) => {
437
- if (payload.type === 'codex_state') {
438
- try { observeCodexResults(session, payload.state); }
443
+ broadcast: (session, payload, event, metadata) => {
444
+ if (payload.type === 'codex_state' && event?.method !== 'item/agentMessage/delta') {
445
+ try { observeCodexResults(session, payload.state, event); }
439
446
  catch (error) { console.error(redactedLogText(`Result evidence could not be saved: ${error.code || 'UNKNOWN'}`)); }
440
447
  }
441
- broadcast(session, payload);
448
+ broadcast(session, payload, metadata);
442
449
  }, closeSessionClients, now, addEvent
443
450
  });
444
451
 
445
452
  const restoredTmuxDbs = new Set();
446
- const { discoverNativeSessions, nativeAction, closeNativeBridge } = createNativeSessions({
453
+ const { startNativeSession, listNativeHistory, nativeWorkerHistory, resumeNativeSession, discoverNativeSessions, nativeAction, closeNativeBridge } = createNativeSessions({
447
454
  sessions, sessionKey, connectWebProject, broadcast, closeSessionClients, now, addEvent
448
455
  });
449
456
  let nativeScanRunning = false;
450
457
  async function scanNativeProjects() {
451
- if (nativeScanRunning) return;
458
+ if (stopping || nativeScanRunning) return;
452
459
  nativeScanRunning = true;
453
460
  try { await Promise.allSettled(runtimeProjectContexts().map(discoverNativeSessions)); }
454
461
  finally { nativeScanRunning = false; }
455
462
  }
456
463
  await scanNativeProjects();
457
- const nativePoller = setInterval(() => { void scanNativeProjects(); }, 3000);
464
+ const nativePoller = setInterval(() => {
465
+ void scanNativeProjects().finally(dropIdleProjectContextDescriptors);
466
+ }, 3000);
458
467
  nativePoller.unref?.();
459
468
  for (const projectCtx of runtimeProjectContexts()) {
460
469
  const dbKey = path.resolve(projectCtx.dbPath);
461
470
  if (restoredTmuxDbs.has(dbKey)) continue;
462
- restoredTmuxDbs.add(dbKey);
463
- restoreTmuxManagedSessions(projectCtx);
464
- reconcileRunningBindings(projectCtx);
471
+ try {
472
+ retainProjectContextDescriptor(projectCtx);
473
+ restoreTmuxManagedSessions(projectCtx);
474
+ reconcileRunningBindings(projectCtx);
475
+ restoredTmuxDbs.add(dbKey);
476
+ } catch (error) {
477
+ // A path can change after preflight but before the first DB open. The
478
+ // pane belongs to the original directory, so leave it running and do
479
+ // not adopt or reconcile it through the replacement pathname.
480
+ if (error?.code !== 'PROJECT_PATH_CHANGED') throw error;
481
+ }
465
482
  }
466
483
  runAutoGc();
467
484
 
@@ -471,24 +488,28 @@ const {
471
488
  broadcast, serializeSession, sessionsForProject, resolveSessionPeerId,
472
489
  parseCookieSid, closeWebSession, issueSession, sessionCookieHeader,
473
490
  expiredSessionCookieHeader, cookieSessionOk, webAuthMode,
474
- rememberProject, knownProjects, resolveWebProjectContext,
491
+ rememberProject, knownProjects, selectedProjectIdentity, resolveWebProjectContext,
475
492
  connectWebProject, projectFromRequest, getSession, resolveWebActionSession,
476
493
  prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
477
494
  detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
478
495
  killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
479
496
  startCodexSession, codexAction, stopCodexSession, assertWebWrite,
480
497
  listCodexThreads, readCodexThread, forkCodexThread, readSessionResults, writeSessionResult,
481
- discoverNativeSessions, nativeAction,
498
+ startNativeSession, listNativeHistory, nativeWorkerHistory, resumeNativeSession, discoverNativeSessions, nativeAction,
482
499
  webStatusSnapshot, webPeerActionForProject,
483
500
  now, addEvent, auditPayload, sendMessage,
484
501
  getProcessIdentity: () => processIdentity,
485
502
  getActualPort: () => actualPort,
486
- shutdown, tlsCredentials,
503
+ shutdown, prepareShutdown, isStopping, shutdownDiagnostic, tlsCredentials,
487
504
  renderWebIndex, renderWebLogin, sendWebHtml, webErrorStatus,
488
505
  ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
489
506
  });
490
507
 
491
508
  const dispatchHttp = (req, res) => {
509
+ if (req.method === 'POST' && req.url?.split('?')[0] === '/api/runtime/stop') {
510
+ shutdownDiagnostic('request_received');
511
+ res.once('finish', () => shutdownDiagnostic('response_finished'));
512
+ }
492
513
  void handleWebRequest(req, res).catch((error) => {
493
514
  try {
494
515
  console.error(redactedLogText(`HTTP request failed: ${error.code || 'UNKNOWN'}`));
@@ -506,6 +527,10 @@ const {
506
527
  const wss = new WebSocketServer({ noServer: true });
507
528
  server.on('upgrade', (req, socket, head) => {
508
529
  try {
530
+ if (stopping) {
531
+ socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
532
+ return;
533
+ }
509
534
  const url = requestUrl(req);
510
535
  if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
511
536
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
@@ -547,7 +572,9 @@ const {
547
572
  socket.destroy();
548
573
  return;
549
574
  }
550
- const reqCtx = projectFromRequest(req, url);
575
+ const reqCtx = projectFromRequest(req, url, {
576
+ requireIdentity: upgradeAuthMode === 'cookie' || cookieSessionOk(req)
577
+ });
551
578
  const id = decodeURIComponent(match[1]);
552
579
  const lookupDb = connectWebProject(reqCtx);
553
580
  let session;
@@ -561,6 +588,11 @@ const {
561
588
  socket.destroy();
562
589
  return;
563
590
  }
591
+ const assertSessionProjectUnchanged = () => {
592
+ reqCtx.rootIdentity?.assertUnchanged();
593
+ session.ctx?.rootIdentity?.assertUnchanged();
594
+ };
595
+ assertSessionProjectUnchanged();
564
596
  // ws-5: cap concurrent WS clients per session so the action_token (delivered
565
597
  // via the snapshot frame) cannot be harvested by unlimited connections.
566
598
  if (session.clients.size >= 4) {
@@ -569,8 +601,11 @@ const {
569
601
  return;
570
602
  }
571
603
  wss.handleUpgrade(req, socket, head, (ws) => {
604
+ try { assertSessionProjectUnchanged(); }
605
+ catch { ws.close(1008, 'project changed'); return; }
572
606
  const connectionActionToken = newSessionActionToken();
573
607
  ws.hccCookieAuth = cookieAuth;
608
+ ws.hccStateSync = url.searchParams.get('state_sync') === '1' && ['app-server', 'native'].includes(session.type);
574
609
  session.clients.add(ws);
575
610
  if (cookieAuth) cookieAuth.session.sockets.add(ws);
576
611
  if (!cookieSocketValid(ws)) {
@@ -585,12 +620,21 @@ const {
585
620
  const localClients = session.type === 'tmux' ? serializeSession(session).local_clients : null;
586
621
  ws.send(JSON.stringify({ type: 'snapshot', data: refreshTmuxSnapshot(session),
587
622
  action_token: connectionActionToken, control: controlLease.status(session, connectionActionToken),
588
- local_clients: localClients, ...(session.adapter ? { state: session.adapter.snapshot() } : {}),
589
- ...(session.type === 'native' ? { native_state: session.nativeSnapshot() } : {}) }));
623
+ local_clients: localClients, ...(!ws.hccStateSync && session.adapter ? { state: session.adapter.snapshot() } : {}),
624
+ ...(!ws.hccStateSync && session.type === 'native' ? { native_state: session.nativeSnapshot() } : {}) }));
625
+ if (ws.hccStateSync) sendStateSnapshot(session, ws);
590
626
  ws.on('message', (raw) => {
591
627
  try {
592
- if (!cookieSocketValid(ws)) return;
628
+ if (stopping || !cookieSocketValid(ws) || ws.hccStateClosing) return;
629
+ assertSessionProjectUnchanged();
593
630
  const msg = JSON.parse(String(raw));
631
+ if (msg.type === 'state_sync_request' && ws.hccStateSync) {
632
+ if (msg.protocol !== 1 || typeof msg.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(msg.requestId)) return;
633
+ if (ws.hccLastStateRecovery && Date.now() - ws.hccLastStateRecovery < 100) { ws.hccStateClosing = true; ws.close(1013, 'state recovery rate exceeded'); return; }
634
+ ws.hccLastStateRecovery = Date.now();
635
+ sendStateSnapshot(session, ws, msg.requestId);
636
+ return;
637
+ }
594
638
  if (msg.type === 'input' && session.status === 'running') {
595
639
  if (!tokenMatches(msg.action_token, connectionActionToken)) return;
596
640
  assertSessionControl(session, connectionActionToken, msg.epoch);
@@ -618,6 +662,7 @@ const {
618
662
  ws.send(JSON.stringify({ type: 'error', error: { code: error.code || 'BAD_REQUEST',
619
663
  message: error.code ? error.message : 'Malformed terminal frame' },
620
664
  control: controlLease.status(session, connectionActionToken) }));
665
+ if (error?.code === 'PROJECT_PATH_CHANGED') ws.close(1008, 'project changed');
621
666
  }
622
667
  });
623
668
  ws.on('close', () => {
@@ -635,54 +680,53 @@ const {
635
680
  });
636
681
 
637
682
  let cleanupPromise = null;
683
+ let shutdownPromise = null;
638
684
  let shutdownExitCode = 0;
639
- async function performRuntimeCleanup() {
640
- tmuxClientSnapshot.close();
641
- clearRuntime(ctx);
642
- // rob-07: clear this runtime's per-project pointers for every registered
643
- // project, not just the primary ctx, so sibling projects don't keep
644
- // pointing at a now-dead runtime.
645
- try {
646
- for (const project of readProjectRegistry()) {
647
- clearRuntime({ root: project.root }, process.pid);
648
- }
649
- } catch {}
650
- // sess-02: mark managed (tmux/pty) sessions detached in their own project
651
- // DB and clear their runtime_target, so a restart does not consume stale
652
- // bindings. Best-effort; never block shutdown.
653
- try {
654
- const byRoot = new Map();
655
- for (const s of sessions.values()) {
656
- if (s.status !== 'running' || (s.type !== 'tmux' && s.type !== 'pty')) continue;
657
- const peerId = s.peerId || s.id;
658
- if (!peerId) continue;
659
- const root = path.resolve(s.ctx?.root || s.root || ctx.root);
660
- if (!byRoot.has(root)) byRoot.set(root, new Set());
661
- byRoot.get(root).add(peerId);
662
- }
663
- const detachedAt = now();
664
- for (const [root, idSet] of byRoot) {
665
- const peerIds = [...idSet];
666
- if (!peerIds.length) continue;
667
- const placeholders = peerIds.map(() => '?').join(',');
668
- const cleanupDb = connectWebProject(contextForProject(root, null, { json: ctx.json }));
669
- try {
670
- cleanupDb.prepare(`UPDATE peers SET status = 'detached' WHERE id IN (${placeholders}) AND status IN ('running','working','busy')`).run(...peerIds);
671
- cleanupDb.prepare(`UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer IN (${placeholders})`).run(detachedAt, ...peerIds);
672
- } finally {
673
- cleanupDb.close();
674
- }
675
- }
676
- } catch {}
685
+ function prepareShutdown() {
686
+ if (stopping) return;
687
+ stopping = true;
688
+ try { tmuxClientSnapshot.close(); } catch {}
677
689
  clearInterval(externalScanPoller);
678
690
  clearInterval(autoAttachPoller);
679
691
  clearInterval(reaperPoller);
680
692
  clearInterval(gcPoller);
681
693
  clearInterval(webSessionPruner);
682
694
  clearInterval(nativePoller);
683
- closeNativeBridge();
684
- try { for (const w of bufsWatchers.values()) w.close(); } catch {}
695
+ try { closeNativeBridge(); } catch {}
696
+ stopExternalDiscovery();
697
+ for (const watcher of bufsWatchers.values()) {
698
+ try { watcher.close(); } catch {}
699
+ }
700
+ bufsWatchers.clear();
685
701
  clearInterval(bufsWatcherSyncPoller);
702
+ // Stop queued observation work before yielding to response delivery or
703
+ // adapter close. Handles and owner-fenced state cleanup remain below.
704
+ for (const session of sessions.values()) {
705
+ for (const field of ['outputPoller', 'exitPoller', 'streamPoller', 'replacePoller']) {
706
+ if (session[field]) clearInterval(session[field]);
707
+ session[field] = null;
708
+ }
709
+ for (const field of ['replaceTimer', 'inputRefreshTimer']) {
710
+ if (session[field]) clearTimeout(session[field]);
711
+ session[field] = null;
712
+ }
713
+ }
714
+ shutdownDiagnostic('pollers_closed');
715
+ }
716
+ async function performRuntimeCleanup() {
717
+ const counts = { sessions: sessions.size, running: 0, tmux: 0, appServer: 0, native: 0, external: 0, pty: 0 };
718
+ for (const session of sessions.values()) {
719
+ if (session.status === 'running') counts.running++;
720
+ const type = session.type === 'app-server' ? 'appServer' : session.type || 'pty';
721
+ if (Object.hasOwn(counts, type) && !['sessions', 'running'].includes(type)) counts[type]++;
722
+ }
723
+ shutdownDiagnostic('cleanup_begin', counts);
724
+ cleanupRuntimeState({ ctx, sessions, connectWebProject, now, ownerIdentity: processIdentity });
725
+ shutdownDiagnostic('state_cleaned');
726
+ // Bound admitted tmux work across the whole session set. Nonblocking FIFO
727
+ // leases avoid contention waits; worker startup/release have their own
728
+ // bounds, so this is not a hard deadline for the entire runtime shutdown.
729
+ const tmuxShutdownDeadline = performance.now() + 1000;
686
730
  for (const session of sessions.values()) {
687
731
  controlLease.forget(session);
688
732
  closeSessionClients(session);
@@ -697,12 +741,13 @@ const {
697
741
  try { if (session.outputPoller) clearInterval(session.outputPoller); } catch {}
698
742
  try { if (session.exitPoller) clearInterval(session.exitPoller); } catch {}
699
743
  } else if (session.type === 'tmux') {
700
- try { stopTmuxStream(session); } catch {}
744
+ try { stopTmuxStream(session, { shutdownDeadline: tmuxShutdownDeadline }); } catch {}
701
745
  try { if (session.exitPoller) clearInterval(session.exitPoller); } catch {}
702
746
  } else {
703
747
  try { session.pty.kill(); } catch {}
704
748
  }
705
749
  }
750
+ shutdownDiagnostic('sessions_cleaned');
706
751
  try { wss.close(); } catch {}
707
752
  await new Promise((resolve) => {
708
753
  const terminateClients = setTimeout(() => {
@@ -730,19 +775,26 @@ const {
730
775
  resolve();
731
776
  }
732
777
  });
778
+ shutdownDiagnostic('http_closed');
779
+ releaseProjectContexts();
780
+ shutdownDiagnostic('cleanup_end');
733
781
  }
734
782
  function cleanupRuntime() {
735
- if (!cleanupPromise) cleanupPromise = performRuntimeCleanup();
783
+ // Publish the shared promise before cleanup can synchronously emit events.
784
+ if (!cleanupPromise) cleanupPromise = Promise.resolve().then(performRuntimeCleanup);
785
+ prepareShutdown();
736
786
  return cleanupPromise;
737
787
  }
738
788
  function shutdown() {
739
- void cleanupRuntime().then(
789
+ if (shutdownPromise) return shutdownPromise;
790
+ shutdownPromise = cleanupRuntime().then(
740
791
  () => process.exit(shutdownExitCode),
741
792
  (error) => {
742
793
  console.error(redactedLogText(`[${new Date().toISOString()}] runtime cleanup failed: ${error?.stack || error}`));
743
794
  process.exit(1);
744
795
  }
745
796
  );
797
+ return shutdownPromise;
746
798
  }
747
799
  process.once('SIGINT', shutdown);
748
800
  process.once('SIGTERM', shutdown);
@@ -1,6 +1,9 @@
1
1
  // Session serialization, broadcast, and client management helpers extracted from cmdWeb.
2
2
  // These operate on the shared sessions Map and WS clients.
3
+ import path from 'node:path';
3
4
  import { selectCurrentTask } from '../core/coordination/automation.mjs';
5
+ import { sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
6
+ import { createSessionStateStream, sendStructuredFrame } from './session-state-sync.mjs';
4
7
 
5
8
  export function createSessionSerialize({
6
9
  sessions,
@@ -13,9 +16,21 @@ export function createSessionSerialize({
13
16
  return `${projectCtx.root}\0${id}`;
14
17
  }
15
18
 
19
+ function sessionIdentityMatches(session, projectCtx) {
20
+ if (session.ctx?.dbPath && projectCtx.dbPath &&
21
+ path.resolve(session.ctx.dbPath) !== path.resolve(projectCtx.dbPath)) return false;
22
+ const owner = session.ctx?.rootIdentity || session.rootIdentity;
23
+ const requested = projectCtx.rootIdentity;
24
+ // Legacy contexts without a captured identity retain their old path
25
+ // behavior; newly managed sessions cannot appear under a rebound root.
26
+ return owner || requested
27
+ ? Boolean(owner && requested && sameSelectedCwdIdentity(owner, requested))
28
+ : true;
29
+ }
30
+
16
31
  function sessionsForProject(projectCtx) {
17
32
  return [...sessions.values()].filter((session) =>
18
- sameResolvedPath(session.root, projectCtx.root));
33
+ sameResolvedPath(session.root, projectCtx.root) && sessionIdentityMatches(session, projectCtx));
19
34
  }
20
35
 
21
36
  function resolveSessionPeerId(db, session) {
@@ -101,9 +116,10 @@ export function createSessionSerialize({
101
116
  type: session.type || 'pty',
102
117
  task,
103
118
  capabilities: session.type === 'app-server'
104
- ? ['chat', 'turn-control', 'approval', 'diff', 'plan', 'results', 'history']
119
+ ? ['chat', 'turn-control', 'approval', 'diff', 'plan', 'results', 'history', 'account-read']
105
120
  : session.type === 'native' ? ['chat', 'delivery-receipts', 'results',
106
- ...(session.nativeState?.capabilities?.interrupt ? ['turn-control'] : [])] : ['terminal', 'results'],
121
+ ...(session.nativeState?.capabilities?.interrupt ? ['turn-control'] : []),
122
+ ...(session.nativeState?.capabilities?.accountRead ? ['account-read'] : [])] : ['terminal', 'results'],
107
123
  local_clients: session.type === 'tmux' ? localClientObservation(session) : null,
108
124
  executor_id: session.executorId || session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
109
125
  native_connected: session.type === 'native' ? Boolean(session.nativeState?.connected) : null,
@@ -116,11 +132,73 @@ export function createSessionSerialize({
116
132
  };
117
133
  }
118
134
 
119
- function broadcast(session, payload) {
120
- const text = JSON.stringify(payload);
121
- for (const client of [...session.clients]) {
122
- if (!cookieSocketValid(client)) { session.clients.delete(client); continue; }
123
- if (client.readyState === client.OPEN) client.send(text);
135
+ function stateStream(session, channel, state, connectingClient = null) {
136
+ if (!session.stateSync || session.stateSync.executorId !== String(state.executorId || state.owner || '')) {
137
+ if (session.stateSync) for (const client of session.clients) {
138
+ if (client.hccStateSync && client !== connectingClient) { client.hccStateClosing = true; client.close(1012, 'executor changed'); }
139
+ }
140
+ session.stateSync = createSessionStateStream({ root: session.root || session.ctx?.root || ctx.root,
141
+ sessionId: session.id, channel, state });
142
+ return { stream: session.stateSync, initial: true };
143
+ }
144
+ return { stream: session.stateSync, initial: false };
145
+ }
146
+
147
+ function sendStateSnapshot(session, client, requestId) {
148
+ if (!sessionProjectStillSelected(session)) return false;
149
+ if (!client.hccStateSync || client.hccStateClosing) return false;
150
+ const channel = session.type === 'native' ? 'native' : session.adapter ? 'codex' : null;
151
+ if (!channel) return false;
152
+ const state = channel === 'native' ? session.nativeSnapshot() : session.adapter.snapshot();
153
+ const { stream, initial } = stateStream(session, channel, state, client.hccStateInitialized ? null : client);
154
+ if (!initial) {
155
+ const frame = stream.update(state);
156
+ // A snapshot can observe deltas waiting in the adapter's 50ms batch.
157
+ // Advance existing observers too, so their next baseRevision still fits.
158
+ if (frame) {
159
+ const encoded = JSON.stringify(frame);
160
+ for (const observer of session.clients) if (observer !== client && observer.hccStateSync && cookieSocketValid(observer)) sendStructuredFrame(observer, encoded);
161
+ }
162
+ }
163
+ const sent = sendStructuredFrame(client, JSON.stringify(stream.snapshot(requestId)));
164
+ if (sent) client.hccStateInitialized = true;
165
+ return sent;
166
+ }
167
+
168
+ function sessionProjectStillSelected(session) {
169
+ try {
170
+ (session.ctx?.rootIdentity || session.rootIdentity)?.assertUnchanged();
171
+ return true;
172
+ } catch {
173
+ // Rebinding a selected pathname invalidates Web clients, not the tmux
174
+ // pane or native worker that still belongs to the original directory.
175
+ for (const client of session.clients || []) {
176
+ try { client.close(1008, 'project changed'); } catch {}
177
+ }
178
+ return false;
179
+ }
180
+ }
181
+
182
+ function broadcast(session, payload, metadata) {
183
+ if (!sessionProjectStillSelected(session)) return;
184
+ const structured = ['codex_state', 'native_state'].includes(payload.type);
185
+ const clients = [...session.clients].filter(client => {
186
+ if (!cookieSocketValid(client)) { session.clients.delete(client); return false; }
187
+ return client.readyState === client.OPEN && !client.hccStateClosing;
188
+ });
189
+ if (!clients.length) return;
190
+ let synchronizedText = null;
191
+ if (structured && clients.some(client => client.hccStateSync)) {
192
+ const { stream, initial } = stateStream(session, payload.type === 'codex_state' ? 'codex' : 'native', payload.state);
193
+ const frame = initial ? stream.snapshot() : stream.update(payload.state, metadata);
194
+ if (frame) synchronizedText = JSON.stringify(frame);
195
+ }
196
+ const text = !structured || clients.some(client => !client.hccStateSync) ? JSON.stringify(payload) : null;
197
+ for (const client of clients) {
198
+ if (structured) {
199
+ const encoded = client.hccStateSync ? synchronizedText : text;
200
+ if (encoded) sendStructuredFrame(client, encoded);
201
+ } else client.send(text);
124
202
  }
125
203
  }
126
204
 
@@ -157,6 +235,7 @@ export function createSessionSerialize({
157
235
  serializeBindingSummary,
158
236
  serializeSession,
159
237
  broadcast,
238
+ sendStateSnapshot,
160
239
  hasOpenClients,
161
240
  closeSessionClients
162
241
  };