@oar-sh/oar 0.9.10 → 0.9.12
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 +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
normalizeWorkspaceRootKey,
|
|
25
25
|
} from './services/workspace-root-path-policy.mjs';
|
|
26
26
|
import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
|
|
27
|
+
import { createDetachedWorkerWatch } from './services/detached-worker-watch-service.mjs';
|
|
27
28
|
import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
|
|
28
29
|
import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
|
|
29
30
|
import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
|
|
@@ -45,6 +46,9 @@ import { registerPreviewRoutes } from './routes/preview-routes.mjs';
|
|
|
45
46
|
import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
|
|
46
47
|
import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
|
|
47
48
|
import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
|
|
49
|
+
import { createConversationGroupRepository } from './repositories/conversation-group-repository.mjs';
|
|
50
|
+
import { createShareRepository } from './repositories/share-repository.mjs';
|
|
51
|
+
import { registerConversationGroupRoutes } from './routes/conversation-group-routes.mjs';
|
|
48
52
|
import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
|
|
49
53
|
import { createRemoteRelayLoopbackClient } from './services/remote-relay-loopback.mjs';
|
|
50
54
|
import { createRemoteRelayRegistry } from './services/remote-relay-registry-service.mjs';
|
|
@@ -69,15 +73,24 @@ import webpush from 'web-push';
|
|
|
69
73
|
import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
|
|
70
74
|
import { createHostSuspendService } from './services/host-suspend-service.mjs';
|
|
71
75
|
import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
|
|
72
|
-
import { createSessionActivityTracker } from './services/session-activity-tracker.mjs';
|
|
76
|
+
import { createSessionActivityTracker, resolveBootReportingWindowMs } from './services/session-activity-tracker.mjs';
|
|
77
|
+
import { createBackgroundTaskStore } from './services/background-task-store.mjs';
|
|
78
|
+
import { createBackgroundWorkView } from './services/background-work-view.mjs';
|
|
73
79
|
import { createRelayEventService } from './services/relay-event-service.mjs';
|
|
74
80
|
import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
|
|
75
81
|
import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
|
|
82
|
+
import {
|
|
83
|
+
claudeBackgroundCommandLimitMinutesToMs,
|
|
84
|
+
parseClaudeBackgroundCommandLimitUpdate,
|
|
85
|
+
readClaudeBackgroundCommandLimitSetting,
|
|
86
|
+
} from '../shared/claude-background-command-limit.mjs';
|
|
76
87
|
import { createWorkerLaunchNoteService } from './services/worker-launch-note-service.mjs';
|
|
77
88
|
import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
|
|
78
89
|
import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
|
|
79
90
|
import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
|
|
80
91
|
import { registerPushRoutes } from './routes/push-routes.mjs';
|
|
92
|
+
import { registerAboutRoutes } from './routes/about-routes.mjs';
|
|
93
|
+
import { createAboutService, resolveBuildCommit } from './services/about-service.mjs';
|
|
81
94
|
import { createImageOperationService } from './services/image-operation-service.mjs';
|
|
82
95
|
import {
|
|
83
96
|
normalizeDriveAbsolutePath as _normalizeDriveAbsolutePath,
|
|
@@ -119,7 +132,16 @@ import {
|
|
|
119
132
|
} from './services/preview-registry-service.mjs';
|
|
120
133
|
import { createPreviewProxyServer } from './services/preview-proxy-server.mjs';
|
|
121
134
|
import { createPreviewHealthProbe } from './services/preview-health-probe.mjs';
|
|
135
|
+
import { createSqlitePreviewStore, isSameHostBoot, readHostBootId } from './services/preview-store.mjs';
|
|
136
|
+
import { validateStaticRootInAny } from './services/preview-static-handler.mjs';
|
|
122
137
|
import { createTunnelWorkerPathGuard } from './services/tunnel-worker-path-guard.mjs';
|
|
138
|
+
import {
|
|
139
|
+
createLocalRequestCheck,
|
|
140
|
+
expressRequestView,
|
|
141
|
+
isSameOriginRequest,
|
|
142
|
+
resolveTunnelMarkerHeaders,
|
|
143
|
+
} from './services/request-boundary.mjs';
|
|
144
|
+
import { createRelayAuth } from './services/relay-auth.mjs';
|
|
123
145
|
import { guardUpgradeSocket } from './services/upgrade-socket-guard.mjs';
|
|
124
146
|
import { createWindowsAutostartService } from './services/windows-autostart-service.mjs';
|
|
125
147
|
import { createWindowsBootAutostartService } from './services/windows-boot-autostart-service.mjs';
|
|
@@ -128,6 +150,8 @@ import { createTmuxInspectorAccessPolicy } from './services/tmux-inspector-acces
|
|
|
128
150
|
import { createTmuxInspectorStreamService } from './services/tmux-inspector-stream-service.mjs';
|
|
129
151
|
import { createTmuxInspectorSocketService } from './services/tmux-inspector-socket-service.mjs';
|
|
130
152
|
import { createSubagentViewerRooms } from './services/subagent-viewer-rooms.mjs';
|
|
153
|
+
import { createTaskOutputRooms } from './services/task-output-rooms.mjs';
|
|
154
|
+
import { createTaskHealthPoller, createTaskOutputSnapshots } from './services/task-output-service.mjs';
|
|
131
155
|
import {
|
|
132
156
|
resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
|
|
133
157
|
resolveLaunchWorkspaceRootPath,
|
|
@@ -154,6 +178,11 @@ import {
|
|
|
154
178
|
readSubagentRecordRetentionSetting,
|
|
155
179
|
} from '../shared/subagent-record-retention.mjs';
|
|
156
180
|
import { createSubagentRecordRetention } from './services/subagent-record-retention.mjs';
|
|
181
|
+
import {
|
|
182
|
+
SHARE_PATH_PRIVACY_SETTING_KEY,
|
|
183
|
+
parseSharePathPrivacyUpdate,
|
|
184
|
+
readSharePathPrivacySetting,
|
|
185
|
+
} from '../shared/share-path-privacy.mjs';
|
|
157
186
|
import { isRelayShutdownQueued, queuedShutdownCanProceed, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
|
|
158
187
|
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
159
188
|
import {
|
|
@@ -186,6 +215,11 @@ import {
|
|
|
186
215
|
readBackgroundTaskTimeoutSetting,
|
|
187
216
|
backgroundTaskTimeoutMinutesToMs,
|
|
188
217
|
} from '../shared/background-task-timeout.mjs';
|
|
218
|
+
import {
|
|
219
|
+
TASK_STALL_MINUTES_SETTING_KEY,
|
|
220
|
+
parseTaskStallMinutesUpdate,
|
|
221
|
+
readTaskStallMinutesSetting,
|
|
222
|
+
} from '../shared/task-output-text.mjs';
|
|
189
223
|
import { normalizeRelayThoughtList } from './public/app/relay-thoughts.mjs';
|
|
190
224
|
import { capRelayActivityEntries } from './public/app/activity-replay-state.mjs';
|
|
191
225
|
import {
|
|
@@ -198,6 +232,7 @@ import {
|
|
|
198
232
|
import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
|
|
199
233
|
import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
|
|
200
234
|
import { mapSubagentRunHeader } from './services/subagent-timeline-service.mjs';
|
|
235
|
+
import { describeExecError, describeSpawnEnvironment, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
201
236
|
|
|
202
237
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
203
238
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
@@ -215,6 +250,10 @@ const RUNNING_VERSION = (() => {
|
|
|
215
250
|
}
|
|
216
251
|
})();
|
|
217
252
|
const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-checkout' : 'npm-global';
|
|
253
|
+
// When this relay process started, and the commit its code came from (read
|
|
254
|
+
// once: a checkout moved on later still runs the code it started with).
|
|
255
|
+
const RELAY_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
256
|
+
const BUILD_COMMIT = resolveBuildCommit(PACKAGE_ROOT);
|
|
218
257
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
219
258
|
const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
|
|
220
259
|
const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
|
|
@@ -477,10 +516,15 @@ function getClaudeProviderSettings() {
|
|
|
477
516
|
effortsByModel,
|
|
478
517
|
modelsDiscovered,
|
|
479
518
|
attributionMode: normalizeClaudeAttributionMode(readAppSettingValue(CLAUDE_ATTRIBUTION_SETTING_KEY)) || DEFAULT_CLAUDE_ATTRIBUTION_MODE,
|
|
519
|
+
backgroundCommandLimitMinutes: getClaudeBackgroundCommandLimitMinutes(),
|
|
480
520
|
providerType: 'claude',
|
|
481
521
|
};
|
|
482
522
|
}
|
|
483
523
|
|
|
524
|
+
function getClaudeBackgroundCommandLimitMinutes() {
|
|
525
|
+
return readClaudeBackgroundCommandLimitSetting(readAppSettingValue(CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY));
|
|
526
|
+
}
|
|
527
|
+
|
|
484
528
|
function setClaudeProviderSettings(update = {}) {
|
|
485
529
|
const payload = update && typeof update === 'object' ? update : {};
|
|
486
530
|
const enabled = payload.enabled;
|
|
@@ -501,6 +545,12 @@ function setClaudeProviderSettings(update = {}) {
|
|
|
501
545
|
if (attributionMode === null) {
|
|
502
546
|
return { ok: false, error: 'Invalid Claude attribution mode' };
|
|
503
547
|
}
|
|
548
|
+
const backgroundCommandLimit = payload.backgroundCommandLimitMinutes === undefined
|
|
549
|
+
? null
|
|
550
|
+
: parseClaudeBackgroundCommandLimitUpdate(payload.backgroundCommandLimitMinutes);
|
|
551
|
+
if (backgroundCommandLimit && !backgroundCommandLimit.ok) {
|
|
552
|
+
return { ok: false, error: backgroundCommandLimit.error };
|
|
553
|
+
}
|
|
504
554
|
const nowIso = new Date().toISOString();
|
|
505
555
|
const nextEnabled = typeof enabled === 'boolean' ? enabled : existing.enabled;
|
|
506
556
|
stmts.upsertAppSetting.run(CLAUDE_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', nowIso);
|
|
@@ -508,6 +558,9 @@ function setClaudeProviderSettings(update = {}) {
|
|
|
508
558
|
if (attributionMode) {
|
|
509
559
|
stmts.upsertAppSetting.run(CLAUDE_ATTRIBUTION_SETTING_KEY, attributionMode, nowIso);
|
|
510
560
|
}
|
|
561
|
+
if (backgroundCommandLimit) {
|
|
562
|
+
stmts.upsertAppSetting.run(CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY, String(backgroundCommandLimit.minutes), nowIso);
|
|
563
|
+
}
|
|
511
564
|
if (Array.isArray(models)) {
|
|
512
565
|
const normalizedModels = models
|
|
513
566
|
.map((value) => String(value || '').trim())
|
|
@@ -1377,6 +1430,9 @@ const CLAUDE_MODEL_EFFORTS_SETTING_KEY = 'claude_model_efforts';
|
|
|
1377
1430
|
// oar | vanilla | off — what commits and PRs made in Claude sessions say
|
|
1378
1431
|
// (shared/claude-attribution.mjs); absent means oar.
|
|
1379
1432
|
const CLAUDE_ATTRIBUTION_SETTING_KEY = 'claude_attribution_mode';
|
|
1433
|
+
// Minutes a backgrounded Bash command of a Claude session may run without its
|
|
1434
|
+
// own timeout (shared/claude-background-command-limit.mjs); absent means 2 h.
|
|
1435
|
+
const CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY = 'claude_background_command_limit_minutes';
|
|
1380
1436
|
// The set of levels the SDK itself can report/accept as EffortLevel values.
|
|
1381
1437
|
// Discovery results are filtered through this, so it must NOT contain the
|
|
1382
1438
|
// relay's derived 'ultracode' sentinel — supportedModels() never reports it
|
|
@@ -2038,6 +2094,35 @@ function setSubagentRecordRetentionDays(value) {
|
|
|
2038
2094
|
return { ok: true, retentionDays: parsed.days };
|
|
2039
2095
|
}
|
|
2040
2096
|
|
|
2097
|
+
function getSharePathPrivacyMode() {
|
|
2098
|
+
return readSharePathPrivacySetting(readAppSettingValue(SHARE_PATH_PRIVACY_SETTING_KEY));
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function setSharePathPrivacyMode(value) {
|
|
2102
|
+
if (typeof stmts?.upsertAppSetting?.run !== 'function') {
|
|
2103
|
+
return { ok: false, error: 'Path privacy settings are unavailable' };
|
|
2104
|
+
}
|
|
2105
|
+
const parsed = parseSharePathPrivacyUpdate(value);
|
|
2106
|
+
if (!parsed.ok) return parsed;
|
|
2107
|
+
stmts.upsertAppSetting.run(SHARE_PATH_PRIVACY_SETTING_KEY, parsed.mode, new Date().toISOString());
|
|
2108
|
+
return { ok: true, mode: parsed.mode };
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// The relay host's own roots a shared view must not show: home, the relay's
|
|
2112
|
+
// checkout, data and upload dirs, the workspace roots it knows, and the OS
|
|
2113
|
+
// user name. The share routes add the conversation's own roots.
|
|
2114
|
+
function sharePathKnownRoots() {
|
|
2115
|
+
const roots = [REPO_ROOT, DATA_DIR, UPLOAD_DIR, currentWorkspaceRootPath(), INITIAL_WORKSPACE_ROOT];
|
|
2116
|
+
const userNames = [];
|
|
2117
|
+
try { roots.push(os.homedir()); } catch {}
|
|
2118
|
+
try { userNames.push(os.userInfo().username); } catch {}
|
|
2119
|
+
try { roots.push(resolveDefaultSessionWorkspaceRootState()?.path || ''); } catch {}
|
|
2120
|
+
try { roots.push(...listRecentWorkspaceRoots()); } catch {}
|
|
2121
|
+
try { roots.push(resolveSessionStateRoot?.() || ''); } catch {}
|
|
2122
|
+
roots.push(...workspaceRootAllowList);
|
|
2123
|
+
return { roots: roots.filter(Boolean), userNames: userNames.filter(Boolean) };
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2041
2126
|
const PWA_APP_NAME_SETTING_KEY = 'pwa_app_name';
|
|
2042
2127
|
|
|
2043
2128
|
/** '' means "no custom name": the manifest falls back to the template default. */
|
|
@@ -2102,6 +2187,21 @@ function setBackgroundTaskTimeoutMinutes(value) {
|
|
|
2102
2187
|
return { ok: true, timeoutMinutes: parsed.minutes };
|
|
2103
2188
|
}
|
|
2104
2189
|
|
|
2190
|
+
// "Mark a background task as quiet after N minutes": the panel's stall hint.
|
|
2191
|
+
function getTaskStallMinutes() {
|
|
2192
|
+
return readTaskStallMinutesSetting(readAppSettingValue(TASK_STALL_MINUTES_SETTING_KEY));
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function setTaskStallMinutes(value) {
|
|
2196
|
+
if (typeof stmts?.upsertAppSetting?.run !== 'function') {
|
|
2197
|
+
return { ok: false, error: 'Task stall settings are unavailable' };
|
|
2198
|
+
}
|
|
2199
|
+
const parsed = parseTaskStallMinutesUpdate(value);
|
|
2200
|
+
if (!parsed.ok) return parsed;
|
|
2201
|
+
stmts.upsertAppSetting.run(TASK_STALL_MINUTES_SETTING_KEY, String(parsed.minutes), new Date().toISOString());
|
|
2202
|
+
return { ok: true, minutes: parsed.minutes };
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2105
2205
|
function resolveDefaultSessionWorkspaceRootState() {
|
|
2106
2206
|
return resolveDefaultSessionWorkspaceRootStateFromService({
|
|
2107
2207
|
storedPath: readAppSettingValue(DEFAULT_SESSION_WORKSPACE_ROOT_KEY),
|
|
@@ -2508,7 +2608,7 @@ function runCopilotCliCommand(args = [], timeoutMs = 30_000) {
|
|
|
2508
2608
|
if (process.platform === 'win32') {
|
|
2509
2609
|
const escapedArgs = commandArgs.map((part) => `'${String(part).replace(/'/g, "''")}'`).join(' ');
|
|
2510
2610
|
const script = escapedArgs ? `& 'copilot' ${escapedArgs}` : "& 'copilot'";
|
|
2511
|
-
execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, timeout }, (error, stdout, stderr) => {
|
|
2611
|
+
execFile(windowsSystemToolPath('powershell.exe'), ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, timeout }, (error, stdout, stderr) => {
|
|
2512
2612
|
if (error) {
|
|
2513
2613
|
const detail = String(stderr || stdout || error.message || '').trim();
|
|
2514
2614
|
reject(new Error(detail || error.message || 'copilot command failed'));
|
|
@@ -2579,6 +2679,21 @@ const stmts = {
|
|
|
2579
2679
|
...createImageConversationRepository(db),
|
|
2580
2680
|
};
|
|
2581
2681
|
const statusEventService = createStatusEventService(db);
|
|
2682
|
+
// Public shares and their access log (migration 0016).
|
|
2683
|
+
const shareRepository = createShareRepository(db);
|
|
2684
|
+
// Session groups in the sidebar (migration 0014). Without the migration it
|
|
2685
|
+
// sees no groups and writes nothing. What a delete that did not know groups
|
|
2686
|
+
// left behind (a head whose row is gone, a group without a member) is
|
|
2687
|
+
// repaired once at boot.
|
|
2688
|
+
const conversationGroups = createConversationGroupRepository(db);
|
|
2689
|
+
try {
|
|
2690
|
+
const repaired = conversationGroups.repair();
|
|
2691
|
+
if (repaired.headsDetached > 0 || repaired.groupsRemoved > 0) {
|
|
2692
|
+
console.log(`[session-groups] repaired: ${repaired.headsDetached} headless group(s), ${repaired.groupsRemoved} empty group(s) removed`);
|
|
2693
|
+
}
|
|
2694
|
+
} catch (error) {
|
|
2695
|
+
console.warn(`[session-groups] repair failed: ${error?.message || error}`);
|
|
2696
|
+
}
|
|
2582
2697
|
// Relay events (built further down, next to the usage-limit pause): declared
|
|
2583
2698
|
// here so recovery code that runs before then finds null, not a TDZ error.
|
|
2584
2699
|
let sessionActivityTracker = null;
|
|
@@ -2727,7 +2842,7 @@ const imageOperationService = createImageOperationService({
|
|
|
2727
2842
|
|
|
2728
2843
|
modelVariantCatalog.bindDatabase(db);
|
|
2729
2844
|
|
|
2730
|
-
const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot });
|
|
2845
|
+
const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot, conversationGroups });
|
|
2731
2846
|
void deleteArchiveService.retryPendingDeletesOnStartup()
|
|
2732
2847
|
.then((result) => {
|
|
2733
2848
|
if (result?.pendingCount > 0) {
|
|
@@ -2955,8 +3070,13 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
|
|
|
2955
3070
|
}
|
|
2956
3071
|
// An unreadable process list must not stop every launch on the relay
|
|
2957
3072
|
// (2026-10-01: a control byte in one command line did, for an hour).
|
|
3073
|
+
// The environment goes with the first report of a launch only; the pid poll
|
|
3074
|
+
// reads the list up to ten more times.
|
|
3075
|
+
let processListEnvironmentLogged = false;
|
|
2958
3076
|
const warnProcessListUnreadable = (error) => {
|
|
2959
|
-
|
|
3077
|
+
const environment = processListEnvironmentLogged ? '' : `; ${describeSpawnEnvironment(process.env)}`;
|
|
3078
|
+
processListEnvironmentLogged = true;
|
|
3079
|
+
console.warn(`${runtimeLogPrefix()}worker launcher: process list unreadable, launching session=${normalizedTargetSessionId.slice(0, 8)} without the reuse check: ${describeExecError(error)}${environment}`);
|
|
2960
3080
|
};
|
|
2961
3081
|
if (allowProcessReuse) {
|
|
2962
3082
|
let liveWorker = null;
|
|
@@ -2983,6 +3103,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
|
|
|
2983
3103
|
processInspector: sessionWorkerProcessInspector,
|
|
2984
3104
|
allowProcessReuse,
|
|
2985
3105
|
onProcessListError: warnProcessListUnreadable,
|
|
3106
|
+
log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
|
|
2986
3107
|
});
|
|
2987
3108
|
const workerPid = Number.isInteger(Number(launched?.pid)) ? Number(launched.pid) : null;
|
|
2988
3109
|
const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
|
|
@@ -4372,7 +4493,7 @@ function fetchDriveDirectoryEntries(absoluteDirPath, { includeHidden = false } =
|
|
|
4372
4493
|
'$rows | ConvertTo-Json -Depth 4 -Compress',
|
|
4373
4494
|
].join(' ');
|
|
4374
4495
|
|
|
4375
|
-
execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', psScript], { windowsHide: true }, (err, stdout, stderr) => {
|
|
4496
|
+
execFile(windowsSystemToolPath('powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', psScript], { windowsHide: true }, (err, stdout, stderr) => {
|
|
4376
4497
|
if (err) return cb(new Error(`drive list failed: ${stderr || err.message}`));
|
|
4377
4498
|
const text = String(stdout || '').trim();
|
|
4378
4499
|
if (!text) return cb(null, []);
|
|
@@ -4965,6 +5086,9 @@ function createCompactedConversation(sourceConversationId) {
|
|
|
4965
5086
|
stmts.insertConv.run(targetConversationId, nextTitle, now, now);
|
|
4966
5087
|
stmts.updateConvSeed.run(summarySeed, 1, sourceConversationId, now, targetConversationId);
|
|
4967
5088
|
stmts.markConvCompacted.run(targetConversationId, now, sourceConversationId);
|
|
5089
|
+
// The new conversation stays in the old one's session group, and heads
|
|
5090
|
+
// it if the old one did; the archived source stays a member.
|
|
5091
|
+
conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
|
|
4968
5092
|
targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now);
|
|
4969
5093
|
});
|
|
4970
5094
|
tx();
|
|
@@ -5124,25 +5248,13 @@ function previewCardsForResponse(responseMessageId) {
|
|
|
5124
5248
|
.slice(0, 5);
|
|
5125
5249
|
}
|
|
5126
5250
|
|
|
5127
|
-
// Live background-task sets
|
|
5128
|
-
//
|
|
5129
|
-
//
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
const id = String(conversationId || '').trim();
|
|
5135
|
-
if (!id) return;
|
|
5136
|
-
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
5137
|
-
this.sets.delete(id);
|
|
5138
|
-
return;
|
|
5139
|
-
}
|
|
5140
|
-
this.sets.set(id, { tasks, updatedAt: Date.now() });
|
|
5141
|
-
},
|
|
5142
|
-
get(conversationId) {
|
|
5143
|
-
return this.sets.get(String(conversationId || '').trim())?.tasks || [];
|
|
5144
|
-
},
|
|
5145
|
-
};
|
|
5251
|
+
// Live background-task sets, as the session workers publish them. Memory
|
|
5252
|
+
// only: after a runtime start the heartbeat's digest brings each set back
|
|
5253
|
+
// (services/background-task-store.mjs).
|
|
5254
|
+
const backgroundTaskStore = createBackgroundTaskStore();
|
|
5255
|
+
// The output a worker posts for a task with no output file (the console's
|
|
5256
|
+
// fallback); memory only, an hour.
|
|
5257
|
+
const taskOutputSnapshots = createTaskOutputSnapshots();
|
|
5146
5258
|
|
|
5147
5259
|
// ─── Deferred host suspend ───────────────────────────────────────────────────
|
|
5148
5260
|
// "💤 Suspend host" waits until nothing runs any more: no queued/processing
|
|
@@ -5175,6 +5287,17 @@ function hostActivityTitle(conversationId, fallbackTitle = null) {
|
|
|
5175
5287
|
const title = String(fallbackTitle || stmts.getConv?.get?.(conversationId)?.title || '').trim();
|
|
5176
5288
|
return title || String(conversationId || '').slice(0, 8);
|
|
5177
5289
|
}
|
|
5290
|
+
const backgroundWorkView = createBackgroundWorkView({
|
|
5291
|
+
store: backgroundTaskStore,
|
|
5292
|
+
db,
|
|
5293
|
+
getRegistry: () => sessionWorkerRegistry,
|
|
5294
|
+
getTracker: () => sessionActivityTracker,
|
|
5295
|
+
isWorkerAlive: hostActivityWorkerAlive,
|
|
5296
|
+
sessionIdOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.sdk_session_id,
|
|
5297
|
+
conversationIdOf: (sessionId) => resolveConversationRecord({ conversationId: sessionId, sdkSessionId: sessionId })?.id,
|
|
5298
|
+
providerOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type,
|
|
5299
|
+
titleOf: (conversationId) => hostActivityTitle(conversationId),
|
|
5300
|
+
});
|
|
5178
5301
|
function collectHostActivity({ request = null } = {}) {
|
|
5179
5302
|
const blockers = [];
|
|
5180
5303
|
const turnsByConversation = stmts.listActiveQueueCountsByConversation?.all?.() || [];
|
|
@@ -5198,20 +5321,11 @@ function collectHostActivity({ request = null } = {}) {
|
|
|
5198
5321
|
: (count > 1 ? `${count} turns running` : 'turn running'),
|
|
5199
5322
|
});
|
|
5200
5323
|
}
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
hostSuspendWatchedConversationIds.add(conversationId);
|
|
5207
|
-
const what = tasks.slice(0, 3).map((task) => String(task?.description || task?.taskType || 'task').slice(0, 80)).join('; ');
|
|
5208
|
-
blockers.push({
|
|
5209
|
-
kind: 'background',
|
|
5210
|
-
conversationId,
|
|
5211
|
-
title: hostActivityTitle(conversationId),
|
|
5212
|
-
count: tasks.length,
|
|
5213
|
-
detail: `${tasks.length} background agent${tasks.length === 1 ? '' : 's'}: ${what}`,
|
|
5214
|
-
});
|
|
5324
|
+
// Background work of live workers, and a runtime too young to know of it
|
|
5325
|
+
// (services/background-work-view.mjs).
|
|
5326
|
+
for (const blocker of backgroundWorkView.blockers()) {
|
|
5327
|
+
if (blocker.conversationId) hostSuspendWatchedConversationIds.add(blocker.conversationId);
|
|
5328
|
+
blockers.push(blocker);
|
|
5215
5329
|
}
|
|
5216
5330
|
const runningSubagents = Number(stmts.countRunningSubagentRuns?.get?.()?.cnt || 0);
|
|
5217
5331
|
// CI is only worth a network round-trip for workspaces that were busy since
|
|
@@ -5259,6 +5373,10 @@ const usageLimitPauseService = createUsageLimitPauseService({
|
|
|
5259
5373
|
sessionActivityTracker = createSessionActivityTracker({
|
|
5260
5374
|
db,
|
|
5261
5375
|
onEdge: (edge) => relayEventService?.noteTaskEdge(edge),
|
|
5376
|
+
hasUnreportedBackgroundWork: (conversationId) => backgroundWorkView.hasUnreportedWork(conversationId),
|
|
5377
|
+
listUnreportedBackgroundWork: () => backgroundWorkView.listUnreportedConversationIds(),
|
|
5378
|
+
runListedByWorker: (conversationId, row) => backgroundWorkView.runListed(conversationId, row),
|
|
5379
|
+
bootReportingWindowMs: resolveBootReportingWindowMs(process.env, (line) => console.log(`[activity] ${line}`)),
|
|
5262
5380
|
logger: console,
|
|
5263
5381
|
});
|
|
5264
5382
|
relayEventService = createRelayEventService({
|
|
@@ -5804,9 +5922,11 @@ const app = express();
|
|
|
5804
5922
|
// Trust boundary for X-Forwarded-* headers. Default 'loopback' matches the
|
|
5805
5923
|
// localhost-first deployment (a cloudflared/caddy tunnel runs on the same host
|
|
5806
5924
|
// and connects over 127.0.0.1), so req.protocol/req.secure/req.ip reflect the
|
|
5807
|
-
// real edge only when the immediate peer is trusted — a direct client
|
|
5808
|
-
//
|
|
5809
|
-
//
|
|
5925
|
+
// real edge only when the immediate peer is trusted — a direct client cannot
|
|
5926
|
+
// forge those headers. Code that needs the client's address, host or scheme
|
|
5927
|
+
// reads req.ip / req.protocol / requestHostOf (services/request-boundary.mjs),
|
|
5928
|
+
// never the raw X-Forwarded-* headers. Operators behind a remote proxy can
|
|
5929
|
+
// widen this via config.trustProxy (e.g. a hop count or subnet).
|
|
5810
5930
|
app.set('trust proxy', config.trustProxy ?? 'loopback');
|
|
5811
5931
|
const httpServer = http.createServer(app);
|
|
5812
5932
|
// Node closes an idle keep-alive connection after 5 s. A client that pools
|
|
@@ -5826,15 +5946,23 @@ httpServer.prependListener('upgrade', (req, _socket, _head) => {
|
|
|
5826
5946
|
httpServer.prependListener('upgrade', (_req, socket, _head) => {
|
|
5827
5947
|
guardUpgradeSocket(socket);
|
|
5828
5948
|
});
|
|
5949
|
+
// "Truly local": a loopback peer with no tunnel marker and no forwarding
|
|
5950
|
+
// header (services/request-boundary.mjs). The marker headers are `cf-ray`
|
|
5951
|
+
// plus config.tunnelMarkerHeaders plus COPILOT_TUNNEL_MARKER_HEADERS, merged:
|
|
5952
|
+
// neither source switches the other off.
|
|
5953
|
+
const localRequestCheck = createLocalRequestCheck({
|
|
5954
|
+
markerHeaders: resolveTunnelMarkerHeaders(
|
|
5955
|
+
config.tunnelMarkerHeaders,
|
|
5956
|
+
process.env.COPILOT_TUNNEL_MARKER_HEADERS,
|
|
5957
|
+
),
|
|
5958
|
+
});
|
|
5829
5959
|
// Public tunnels (Cloudflare, or a VPS/Caddy front) forward every path on the
|
|
5830
5960
|
// bound hostname to this port, including the session-worker WebSocket plumbing.
|
|
5831
|
-
// Reject
|
|
5832
|
-
//
|
|
5961
|
+
// Reject every worker-path request that is not truly local before auth; local
|
|
5962
|
+
// workers connect over 127.0.0.1 without proxy traces and are unaffected.
|
|
5833
5963
|
const tunnelWorkerPathGuard = createTunnelWorkerPathGuard({
|
|
5834
5964
|
pathPrefix: remotePath,
|
|
5835
|
-
|
|
5836
|
-
|| process.env.COPILOT_TUNNEL_MARKER_HEADERS
|
|
5837
|
-
|| [],
|
|
5965
|
+
localRequestCheck,
|
|
5838
5966
|
logger: console,
|
|
5839
5967
|
});
|
|
5840
5968
|
httpServer.prependListener('upgrade', (req, socket, _head) => {
|
|
@@ -5846,8 +5974,27 @@ httpServer.prependListener('upgrade', (req, socket, _head) => {
|
|
|
5846
5974
|
// High-volume stream chunks are emitted volatile and deliberately excluded from
|
|
5847
5975
|
// the replay buffer; see the io.volatile.emit('relay_stream') call site.
|
|
5848
5976
|
const SOCKET_RECOVERY_WINDOW_MS = 10 * 60 * 1000;
|
|
5977
|
+
// The browser app opens its socket on the page's own origin (same host, the
|
|
5978
|
+
// `remotePath` socket.io path), so a handshake from another origin is refused:
|
|
5979
|
+
// a page elsewhere must not ride the relay cookie into a live socket. The
|
|
5980
|
+
// host compared is the one the client addressed (X-Forwarded-Host only from a
|
|
5981
|
+
// trusted proxy); config.allowedOrigins lists extra origins for a proxy that
|
|
5982
|
+
// rewrites the Host header. Clients that send no Origin (Node) pass.
|
|
5983
|
+
const socketAllowedOrigins = Array.isArray(config.allowedOrigins) ? config.allowedOrigins : [];
|
|
5984
|
+
const refusedSocketOrigins = new Set();
|
|
5849
5985
|
const io = new Server(httpServer, {
|
|
5850
|
-
|
|
5986
|
+
allowRequest: (req, callback) => {
|
|
5987
|
+
if (isSameOriginRequest(expressRequestView(app, req), { allowedOrigins: socketAllowedOrigins })) {
|
|
5988
|
+
callback(null, true);
|
|
5989
|
+
return;
|
|
5990
|
+
}
|
|
5991
|
+
const origin = String(req.headers?.origin || '').slice(0, 200);
|
|
5992
|
+
if (refusedSocketOrigins.size < 32 && !refusedSocketOrigins.has(origin)) {
|
|
5993
|
+
refusedSocketOrigins.add(origin);
|
|
5994
|
+
console.warn(`[socket] Refused a handshake from origin ${origin} (host ${String(req.headers?.host || '').slice(0, 200)}); list it in allowedOrigins if a proxy rewrites the Host header`);
|
|
5995
|
+
}
|
|
5996
|
+
callback('Origin not allowed', false);
|
|
5997
|
+
},
|
|
5851
5998
|
path: socketIoPath(),
|
|
5852
5999
|
connectionStateRecovery: {
|
|
5853
6000
|
maxDisconnectionDuration: SOCKET_RECOVERY_WINDOW_MS,
|
|
@@ -6174,6 +6321,12 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
6174
6321
|
attribution: claudeAttributionForConversation(conv, out.providerModel || out.model, {
|
|
6175
6322
|
relayFolderFallback: deliveredProvider === 'claude',
|
|
6176
6323
|
}),
|
|
6324
|
+
// The Claude tab's background command limit; the worker
|
|
6325
|
+
// hands it to the CLI it spawns next. Claude Cloud runs no
|
|
6326
|
+
// local CLI, so it does not get one.
|
|
6327
|
+
...(deliveredProvider === 'claude'
|
|
6328
|
+
? { claudeBackgroundCommandLimitMs: claudeBackgroundCommandLimitMinutesToMs(getClaudeBackgroundCommandLimitMinutes()) }
|
|
6329
|
+
: {}),
|
|
6177
6330
|
};
|
|
6178
6331
|
})()),
|
|
6179
6332
|
};
|
|
@@ -6321,6 +6474,7 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
|
|
|
6321
6474
|
if (runtimeShutdownStarted) return;
|
|
6322
6475
|
const sid = String(sessionId || '').trim();
|
|
6323
6476
|
if (!sid) return;
|
|
6477
|
+
detachedWorkerWatch.noteSocketEnded(sid);
|
|
6324
6478
|
const owned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
6325
6479
|
// A dead worker's steering snapshot must not keep holding the composer even
|
|
6326
6480
|
// when it owned no rows (the hold can outlive them, e.g. a continuation's).
|
|
@@ -6378,6 +6532,64 @@ async function sweepDeadWorkerProcessingRowsOnce() {
|
|
|
6378
6532
|
return recovered;
|
|
6379
6533
|
}
|
|
6380
6534
|
|
|
6535
|
+
// A worker alive without its socket while a message waits: replaced after a
|
|
6536
|
+
// grace time (see the service). Stops the way the kill route does, but only
|
|
6537
|
+
// names the registry's pid when the process list confirms it is the
|
|
6538
|
+
// session's worker, then spawns the replacement.
|
|
6539
|
+
async function replaceDetachedSessionWorker(sid) {
|
|
6540
|
+
const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
|
|
6541
|
+
const found = process.platform === 'win32'
|
|
6542
|
+
? await sessionWorkerProcessInspector.findWindowsProcessTreeForSessionAsync(sid, { fresh: true })
|
|
6543
|
+
: await sessionWorkerProcessInspector.findProcessesForSessionAsync(sid, { fresh: true });
|
|
6544
|
+
const foundPids = new Set((found || []).map((entry) => Number(entry?.processId)));
|
|
6545
|
+
const stopped = await stopSessionWorkerProcesses({
|
|
6546
|
+
sdkSessionId: sid,
|
|
6547
|
+
worker: worker && foundPids.has(Number(worker.pid)) ? worker : null,
|
|
6548
|
+
processInspector: sessionWorkerProcessInspector,
|
|
6549
|
+
isPidAliveImpl: isProcessAlive,
|
|
6550
|
+
killTmuxSessionImpl: killTmuxSession,
|
|
6551
|
+
});
|
|
6552
|
+
if (!stopped.ok) return { ok: false, error: stopped.error || 'worker-survived-stop' };
|
|
6553
|
+
sessionWorkerRegistry?.removeWorker?.(sid);
|
|
6554
|
+
sessionWorkerSupervisor?.clearRestartSchedule?.(sid);
|
|
6555
|
+
sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
|
|
6556
|
+
const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid);
|
|
6557
|
+
return { ok: launched?.ok === true, error: launched?.error || null };
|
|
6558
|
+
}
|
|
6559
|
+
|
|
6560
|
+
function sessionHasBackgroundWork(sid) {
|
|
6561
|
+
if ((stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || []).length) return true;
|
|
6562
|
+
if (backgroundWorkView.hasUnreportedWork(sid)) return true;
|
|
6563
|
+
const conversationId = resolveConversationRecord({ conversationId: sid, sdkSessionId: sid })?.id || '';
|
|
6564
|
+
if (backgroundTaskStore.get(sid).length || (conversationId && backgroundTaskStore.get(conversationId).length)) return true;
|
|
6565
|
+
return backgroundWorkView.backgroundBlockers()
|
|
6566
|
+
.some((blocker) => blocker.conversationId === sid || (conversationId && blocker.conversationId === conversationId));
|
|
6567
|
+
}
|
|
6568
|
+
|
|
6569
|
+
const detachedWorkerWatch = createDetachedWorkerWatch({
|
|
6570
|
+
isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
|
|
6571
|
+
&& !runtimeShutdownStarted
|
|
6572
|
+
&& !pendingRelayShutdownRequest,
|
|
6573
|
+
listWaitingSessionIds: () => (stmts.listPendingWorkerOwnerSessionIds?.all?.(25) || [])
|
|
6574
|
+
.map((row) => row?.sdk_session_id),
|
|
6575
|
+
hasWorkerSocket: (sid) => sessionWorkerWebSocketService.hasWorkerSocket(sid) === true,
|
|
6576
|
+
hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
|
|
6577
|
+
hasBackgroundWork: sessionHasBackgroundWork,
|
|
6578
|
+
isLaunchBlocked: (sid) => sessionWorkerSupervisor?.isKillBlocked?.(sid) === true,
|
|
6579
|
+
getLaunchState: (sid) => {
|
|
6580
|
+
const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(sid) || null;
|
|
6581
|
+
if (!lifecycle) return null;
|
|
6582
|
+
return {
|
|
6583
|
+
launchMode: lifecycle.launchMode,
|
|
6584
|
+
launchAtMs: lifecycle.launchAt ? Date.parse(lifecycle.launchAt) : null,
|
|
6585
|
+
awaitingHeartbeat: lifecycle.awaitingHeartbeat === true,
|
|
6586
|
+
};
|
|
6587
|
+
},
|
|
6588
|
+
isWorkerProcessAlive: async (sid) => Boolean(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid)),
|
|
6589
|
+
replaceWorker: replaceDetachedSessionWorker,
|
|
6590
|
+
log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
|
|
6591
|
+
});
|
|
6592
|
+
|
|
6381
6593
|
const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
|
|
6382
6594
|
WebSocketServerImpl: WebSocketServer,
|
|
6383
6595
|
httpServer,
|
|
@@ -6512,7 +6724,27 @@ app.get(['/shared/:token', '/shared/:token/'], (req, res, next) => {
|
|
|
6512
6724
|
try {
|
|
6513
6725
|
const rawToken = String(req.params?.token || '').trim().toLowerCase();
|
|
6514
6726
|
const shareToken = /^[a-f0-9]{32,128}$/.test(rawToken) ? rawToken : '';
|
|
6727
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
6728
|
+
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
6729
|
+
// An unknown, revoked or renewed link is a plain 404: no app shell, and
|
|
6730
|
+
// nothing that tells it apart from a link that never existed.
|
|
6731
|
+
if (!shareToken || !shareRepository.findLiveShare(shareToken)) {
|
|
6732
|
+
res.status(404).type('text/plain').send('Not Found');
|
|
6733
|
+
return;
|
|
6734
|
+
}
|
|
6515
6735
|
const html = renderIndexHtmlWithPwaVersion({ sharedToken: shareToken });
|
|
6736
|
+
res.type('html').send(html);
|
|
6737
|
+
} catch (error) {
|
|
6738
|
+
next(error);
|
|
6739
|
+
}
|
|
6740
|
+
});
|
|
6741
|
+
// "Preview as shared": the shared reader for the owner. The page holds
|
|
6742
|
+
// nothing of the conversation; its data comes from the owner-only preview route.
|
|
6743
|
+
app.get(['/share-preview/:conversationId', '/share-preview/:conversationId/'], (req, res, next) => {
|
|
6744
|
+
try {
|
|
6745
|
+
const conversationId = String(req.params?.conversationId || '').trim();
|
|
6746
|
+
const sharePreviewConversationId = /^[A-Za-z0-9_.:-]{1,200}$/.test(conversationId) ? conversationId : '';
|
|
6747
|
+
const html = renderIndexHtmlWithPwaVersion({ sharePreviewConversationId });
|
|
6516
6748
|
res.setHeader('Cache-Control', 'no-store');
|
|
6517
6749
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
6518
6750
|
res.type('html').send(html);
|
|
@@ -6659,6 +6891,7 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
|
|
|
6659
6891
|
|
|
6660
6892
|
const sharedRouteDeps = {
|
|
6661
6893
|
auth,
|
|
6894
|
+
isTrulyLocalRequest: localRequestCheck.isTrulyLocal,
|
|
6662
6895
|
gitChangesService,
|
|
6663
6896
|
gitRemoteService,
|
|
6664
6897
|
currentWorkspaceRootPath,
|
|
@@ -6778,9 +7011,17 @@ const sharedRouteDeps = {
|
|
|
6778
7011
|
inFlightStateForConversation,
|
|
6779
7012
|
resolveLiveTurnQueueRow,
|
|
6780
7013
|
backgroundTaskStore,
|
|
7014
|
+
backgroundWorkView,
|
|
7015
|
+
taskOutputSnapshots,
|
|
7016
|
+
// A closure: the poller is built further down the file.
|
|
7017
|
+
pokeTaskHealth: () => taskHealthPoller.poke(),
|
|
7018
|
+
getTaskStallMinutes,
|
|
7019
|
+
setTaskStallMinutes,
|
|
6781
7020
|
// A closure, not the registry itself: this deps object is built before the
|
|
6782
7021
|
// preview lane is constructed further down the file.
|
|
6783
7022
|
listConversationPreviews: (conversationId) => previewRegistry.listForConversation(conversationId),
|
|
7023
|
+
closeConversationPreviews: (conversationId) => previewRegistry.closeForConversation(conversationId),
|
|
7024
|
+
listKnownPreviewTokens: () => previewRegistry.knownTokens(),
|
|
6784
7025
|
sendWorkerControl: (sessionId, control) => sessionWorkerWebSocketService.sendControlToSession(sessionId, control),
|
|
6785
7026
|
emitToClientsExceptSessionId,
|
|
6786
7027
|
relayBridgeOwnerService,
|
|
@@ -6844,6 +7085,9 @@ const sharedRouteDeps = {
|
|
|
6844
7085
|
setTurnCeilingMinutes,
|
|
6845
7086
|
getSubagentRecordRetentionDays,
|
|
6846
7087
|
setSubagentRecordRetentionDays,
|
|
7088
|
+
getSharePathPrivacyMode,
|
|
7089
|
+
setSharePathPrivacyMode,
|
|
7090
|
+
sharePathKnownRoots,
|
|
6847
7091
|
getBackgroundTaskTimeoutMinutes,
|
|
6848
7092
|
setBackgroundTaskTimeoutMinutes,
|
|
6849
7093
|
getFeatureFlagsSettingsState,
|
|
@@ -6874,9 +7118,12 @@ const sharedRouteDeps = {
|
|
|
6874
7118
|
windowsBootAutostartService,
|
|
6875
7119
|
pushDispatchService,
|
|
6876
7120
|
remoteRelayInbound,
|
|
7121
|
+
conversationGroups,
|
|
7122
|
+
shareRepository,
|
|
6877
7123
|
};
|
|
6878
7124
|
registerMessagesRoutes(app, sharedRouteDeps);
|
|
6879
7125
|
registerSessionsRoutes(app, sharedRouteDeps);
|
|
7126
|
+
registerConversationGroupRoutes(app, sharedRouteDeps);
|
|
6880
7127
|
registerAskUserRoutes(app, sharedRouteDeps);
|
|
6881
7128
|
registerRelayBoardRoutes(app, sharedRouteDeps);
|
|
6882
7129
|
registerCacheRoutes(app, sharedRouteDeps);
|
|
@@ -6899,6 +7146,30 @@ registerRelayEventRoutes(app, {
|
|
|
6899
7146
|
relayEventService,
|
|
6900
7147
|
remoteRelayInbound,
|
|
6901
7148
|
});
|
|
7149
|
+
registerAboutRoutes(app, {
|
|
7150
|
+
auth,
|
|
7151
|
+
aboutService: createAboutService({
|
|
7152
|
+
db,
|
|
7153
|
+
dbPath: DB_PATH,
|
|
7154
|
+
version: RUNNING_VERSION,
|
|
7155
|
+
commit: BUILD_COMMIT,
|
|
7156
|
+
installMethod: INSTALL_METHOD,
|
|
7157
|
+
startedAt: RELAY_STARTED_AT,
|
|
7158
|
+
packageRoot: PACKAGE_ROOT,
|
|
7159
|
+
getProviderSettings: () => ({
|
|
7160
|
+
github: { enabled: true, engine: getCopilotEngine() },
|
|
7161
|
+
openai: { enabled: getOpenAIProviderSettings()?.enabled === true },
|
|
7162
|
+
claude: { enabled: getClaudeProviderSettings()?.enabled === true },
|
|
7163
|
+
'claude-cloud': { enabled: getClaudeCloudProviderSettings()?.enabled === true },
|
|
7164
|
+
cursor: { enabled: getCursorProviderSettings()?.enabled === true },
|
|
7165
|
+
grok: { enabled: getGrokProviderSettings()?.enabled === true },
|
|
7166
|
+
}),
|
|
7167
|
+
getCachedCliProviders: () => cliInstallService.getCachedStatusSnapshot()?.providers || {},
|
|
7168
|
+
isRelayCliOnline: () => runtimeState.cliOnline === true,
|
|
7169
|
+
listWorkers: () => sessionWorkerRegistry.listWorkers(),
|
|
7170
|
+
getUpdateState: () => buildUpdateStatePayload(),
|
|
7171
|
+
}),
|
|
7172
|
+
});
|
|
6902
7173
|
function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
6903
7174
|
cliLastSeen = null;
|
|
6904
7175
|
const wasOnline = cliOnline;
|
|
@@ -7049,38 +7320,21 @@ function runtimeLogPrefix() {
|
|
|
7049
7320
|
}
|
|
7050
7321
|
|
|
7051
7322
|
// ─── Auth Middleware ──────────────────────────────────────────────────────────
|
|
7052
|
-
//
|
|
7053
|
-
//
|
|
7054
|
-
//
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7323
|
+
// Token check, wrong-token lockout and the Socket.IO handshake gate live in
|
|
7324
|
+
// services/relay-auth.mjs. `auth` stays a hoisted function: the route deps
|
|
7325
|
+
// above hand it out before this line runs.
|
|
7326
|
+
const relayAuth = createRelayAuth({
|
|
7327
|
+
app,
|
|
7328
|
+
getAuthToken: () => config.authToken,
|
|
7329
|
+
cookieName: AUTH_COOKIE,
|
|
7330
|
+
cookiePath: COOKIE_PATH,
|
|
7331
|
+
parseCookies,
|
|
7332
|
+
appendSetCookie,
|
|
7333
|
+
localRequestCheck,
|
|
7334
|
+
logger: console,
|
|
7335
|
+
});
|
|
7061
7336
|
function auth(req, res, next) {
|
|
7062
|
-
|
|
7063
|
-
// req.secure is derived through the configured `trust proxy` boundary, so a
|
|
7064
|
-
// direct (untrusted) client can no longer force a non-Secure cookie by
|
|
7065
|
-
// omitting X-Forwarded-Proto.
|
|
7066
|
-
const secureAttr = req.secure ? '; Secure' : '';
|
|
7067
|
-
// Note: the token is intentionally NOT read from req.body — a request body is
|
|
7068
|
-
// an easy CSRF/logging vector and has no legitimate use here. Header, query
|
|
7069
|
-
// (first-load bootstrap only), and the HttpOnly cookie remain.
|
|
7070
|
-
const token =
|
|
7071
|
-
(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') ||
|
|
7072
|
-
req.query.token ||
|
|
7073
|
-
cookies[AUTH_COOKIE];
|
|
7074
|
-
if (tokensMatch(token, config.authToken)) {
|
|
7075
|
-
if (res && !tokensMatch(cookies[AUTH_COOKIE], config.authToken)) {
|
|
7076
|
-
appendSetCookie(res, `${AUTH_COOKIE}=${encodeURIComponent(config.authToken)}; Path=${COOKIE_PATH}; Max-Age=2592000; SameSite=Lax; HttpOnly${secureAttr}`);
|
|
7077
|
-
}
|
|
7078
|
-
return next();
|
|
7079
|
-
}
|
|
7080
|
-
if (res && cookies[AUTH_COOKIE]) {
|
|
7081
|
-
appendSetCookie(res, `${AUTH_COOKIE}=; Path=${COOKIE_PATH}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; HttpOnly${secureAttr}`);
|
|
7082
|
-
}
|
|
7083
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
7337
|
+
return relayAuth.auth(req, res, next);
|
|
7084
7338
|
}
|
|
7085
7339
|
|
|
7086
7340
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -7244,6 +7498,9 @@ relayEventService.start();
|
|
|
7244
7498
|
relayEventLinkService.start();
|
|
7245
7499
|
runtimeTimers.pendingWorkerPrime = setInterval(() => {
|
|
7246
7500
|
void primePendingSessionWorkers('maintenance');
|
|
7501
|
+
detachedWorkerWatch.sweep().catch((error) => {
|
|
7502
|
+
console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
|
|
7503
|
+
});
|
|
7247
7504
|
}, 5_000);
|
|
7248
7505
|
if (typeof runtimeTimers.pendingWorkerPrime.unref === 'function') runtimeTimers.pendingWorkerPrime.unref();
|
|
7249
7506
|
|
|
@@ -7284,18 +7541,29 @@ function sanitizeRelayQuestionContext(rawContext) {
|
|
|
7284
7541
|
|
|
7285
7542
|
|
|
7286
7543
|
// ─── Socket.io Auth ───────────────────────────────────────────────────────────
|
|
7287
|
-
function socketAuthToken(socket) {
|
|
7288
|
-
const cookies = parseCookies(socket.request.headers.cookie);
|
|
7289
|
-
return socket.handshake.auth?.token || socket.handshake.query?.token || cookies[AUTH_COOKIE];
|
|
7290
|
-
}
|
|
7291
7544
|
// A sub-agent's record goes live only to the pages whose viewer shows it.
|
|
7292
7545
|
const subagentViewerRooms = createSubagentViewerRooms({
|
|
7293
7546
|
getRun: (runId) => stmts.getSubagentRun?.get?.(runId) || null,
|
|
7294
7547
|
});
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7548
|
+
// A background task's output goes live only to the pages whose console shows
|
|
7549
|
+
// it; the task must be in the conversation's live or ended set, under the
|
|
7550
|
+
// conversation's id or its worker's session id (as the output route checks).
|
|
7551
|
+
const taskOutputRooms = createTaskOutputRooms({
|
|
7552
|
+
io,
|
|
7553
|
+
resolveTask: (conversationId, taskId) => {
|
|
7554
|
+
const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
|
|
7555
|
+
if (!conversation) return null;
|
|
7556
|
+
const keys = [conversationId, String(conversation.sdk_session_id || conversationId)];
|
|
7557
|
+
return backgroundTaskStore.findTask(keys, taskId);
|
|
7558
|
+
},
|
|
7298
7559
|
});
|
|
7560
|
+
// The stall hint and the pass/fail chip of the panel's rows: every 10 s over
|
|
7561
|
+
// the live tasks with an output file.
|
|
7562
|
+
const taskHealthPoller = createTaskHealthPoller({ store: backgroundTaskStore, io });
|
|
7563
|
+
taskHealthPoller.start();
|
|
7564
|
+
// The handshake presents the token as `auth.token` or the cookie; a query
|
|
7565
|
+
// `token` is not read (see services/relay-auth.mjs).
|
|
7566
|
+
io.use(relayAuth.socketMiddleware);
|
|
7299
7567
|
|
|
7300
7568
|
io.engine.on('initial_headers', (headers, req) => {
|
|
7301
7569
|
const sessionId = ensureSessionId(req);
|
|
@@ -7307,7 +7575,7 @@ io.on('connection', (socket) => {
|
|
|
7307
7575
|
// socket bypasses the io.use() token gate above. Re-validate here and drop it
|
|
7308
7576
|
// on mismatch so a recovered session still requires the auth token (and a
|
|
7309
7577
|
// rotated token takes effect immediately).
|
|
7310
|
-
if (socket.recovered && !
|
|
7578
|
+
if (socket.recovered && !relayAuth.socketTokenValid(socket)) {
|
|
7311
7579
|
socket.disconnect(true);
|
|
7312
7580
|
return;
|
|
7313
7581
|
}
|
|
@@ -7330,6 +7598,7 @@ io.on('connection', (socket) => {
|
|
|
7330
7598
|
activeDeviceTracker.registerSocket(socket);
|
|
7331
7599
|
tmuxInspectorSocketService.registerSocket(socket);
|
|
7332
7600
|
subagentViewerRooms.registerSocket(socket);
|
|
7601
|
+
taskOutputRooms.registerSocket(socket);
|
|
7333
7602
|
});
|
|
7334
7603
|
|
|
7335
7604
|
// ─── SSH Reverse Tunnel ────────────────────────────────────────────────────────
|
|
@@ -7378,11 +7647,42 @@ const previewsConfig = normalizePreviewsConfig(config.previews || {}, {
|
|
|
7378
7647
|
for (const previewConfigError of previewsConfig.errors) {
|
|
7379
7648
|
console.warn(`${runtimeLogPrefix()}PREVIEW LANE disabled: ${previewConfigError}`);
|
|
7380
7649
|
}
|
|
7650
|
+
// Static previews are jailed to the conversation's workspace root; a preview
|
|
7651
|
+
// registered without a conversation falls back to the server-wide root.
|
|
7652
|
+
const resolvePreviewWorkspaceRoot = (conversationId) => {
|
|
7653
|
+
if (String(conversationId || '').trim()) {
|
|
7654
|
+
const state = resolveConversationWorkspaceState({ conversationId });
|
|
7655
|
+
const rootPath = String(state?.currentWorkspaceRootPath || '').trim();
|
|
7656
|
+
if (rootPath) return rootPath;
|
|
7657
|
+
}
|
|
7658
|
+
return currentWorkspaceRootPath();
|
|
7659
|
+
};
|
|
7660
|
+
// Persisted in the previews table and restored here, each row checked again
|
|
7661
|
+
// with the registration rules (see createPreviewRegistry).
|
|
7381
7662
|
const previewRegistry = createPreviewRegistry({
|
|
7382
7663
|
config: previewsConfig,
|
|
7383
7664
|
onChange: ({ previews }) => {
|
|
7384
7665
|
try { io.emit('previews', { previews }); } catch {}
|
|
7385
7666
|
},
|
|
7667
|
+
store: createSqlitePreviewStore(db, { logger: console }),
|
|
7668
|
+
hostBootId: readHostBootId(),
|
|
7669
|
+
isSameHostBoot,
|
|
7670
|
+
checkConversation: (conversationId) => {
|
|
7671
|
+
const row = stmts.getConvAnyStatus.get(conversationId);
|
|
7672
|
+
return !!row && row.status !== 'deleted';
|
|
7673
|
+
},
|
|
7674
|
+
// At boot no worker has reported, so the configured root comes first while
|
|
7675
|
+
// the preview may have been registered under the worker's runtime root:
|
|
7676
|
+
// either of the conversation's roots keeps it.
|
|
7677
|
+
checkStaticRoot: (rootDir, conversationId) => {
|
|
7678
|
+
const state = String(conversationId || '').trim()
|
|
7679
|
+
? resolveConversationWorkspaceState({ conversationId }) : null;
|
|
7680
|
+
return validateStaticRootInAny(rootDir, [
|
|
7681
|
+
resolvePreviewWorkspaceRoot(conversationId),
|
|
7682
|
+
state?.runtimeWorkspaceRootPath,
|
|
7683
|
+
state?.configuredWorkspaceRootPath,
|
|
7684
|
+
]);
|
|
7685
|
+
},
|
|
7386
7686
|
});
|
|
7387
7687
|
const previewProxyServer = createPreviewProxyServer({
|
|
7388
7688
|
registry: previewRegistry,
|
|
@@ -7406,16 +7706,7 @@ registerPreviewRoutes(app, {
|
|
|
7406
7706
|
auth,
|
|
7407
7707
|
previewRegistry,
|
|
7408
7708
|
previewHealthProbe,
|
|
7409
|
-
|
|
7410
|
-
// registered without a conversation falls back to the server-wide root.
|
|
7411
|
-
resolvePreviewWorkspaceRoot: (conversationId) => {
|
|
7412
|
-
if (String(conversationId || '').trim()) {
|
|
7413
|
-
const state = resolveConversationWorkspaceState({ conversationId });
|
|
7414
|
-
const rootPath = String(state?.currentWorkspaceRootPath || '').trim();
|
|
7415
|
-
if (rootPath) return rootPath;
|
|
7416
|
-
}
|
|
7417
|
-
return currentWorkspaceRootPath();
|
|
7418
|
-
},
|
|
7709
|
+
resolvePreviewWorkspaceRoot,
|
|
7419
7710
|
// A preview published while a turn is processing gets pinned to that turn's
|
|
7420
7711
|
// transcript (snapshot, keyed on the queue id and linked to the response at
|
|
7421
7712
|
// finalize — the relay_activity two-step). No in-flight turn → no card.
|
|
@@ -7440,6 +7731,8 @@ function clearRuntimeTimers() {
|
|
|
7440
7731
|
try { clearInterval(timer); } catch {}
|
|
7441
7732
|
}
|
|
7442
7733
|
subagentRecordRetention?.stop();
|
|
7734
|
+
try { taskHealthPoller.stop(); } catch {}
|
|
7735
|
+
try { taskOutputRooms.stop(); } catch {}
|
|
7443
7736
|
runtimeTimers.cliStatus = null;
|
|
7444
7737
|
runtimeTimers.ownerWatchdog = null;
|
|
7445
7738
|
runtimeTimers.staleRecovery = null;
|
|
@@ -7495,9 +7788,9 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
7495
7788
|
cloudflaredTunnelManager.stop();
|
|
7496
7789
|
previewHealthProbe.stop();
|
|
7497
7790
|
try { remoteRelayRegistry.stopHealthLoop(); } catch {}
|
|
7498
|
-
// Previews
|
|
7499
|
-
//
|
|
7500
|
-
previewRegistry.
|
|
7791
|
+
// Previews persist across the restart (the next runtime restores them); only
|
|
7792
|
+
// the throttled last-use times still need writing.
|
|
7793
|
+
previewRegistry.flushHits();
|
|
7501
7794
|
void previewProxyServer.stop().catch(() => {});
|
|
7502
7795
|
try { relaySingletonGuard.release(); } catch (error) {
|
|
7503
7796
|
console.warn(`${runtimeLogPrefix()}Failed to release singleton lock: ${error?.message || error}`);
|