@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.
- package/CHANGELOG.md +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +1997 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +132 -80
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +105 -85
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +481 -219
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
package/lib/web/runtime-main.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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(() => {
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'] : [])
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
};
|