@oar-sh/oar 0.9.9 → 0.9.11
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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -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/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 +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -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-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- 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 +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- 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/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -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 +49 -27
- 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/push-dispatch-service.mjs +18 -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-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- 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/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -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/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';
|
|
@@ -43,7 +44,11 @@ import { registerGitRoutes } from './routes/git-routes.mjs';
|
|
|
43
44
|
import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
|
|
44
45
|
import { registerPreviewRoutes } from './routes/preview-routes.mjs';
|
|
45
46
|
import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
|
|
47
|
+
import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
|
|
46
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';
|
|
47
52
|
import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
|
|
48
53
|
import { createRemoteRelayLoopbackClient } from './services/remote-relay-loopback.mjs';
|
|
49
54
|
import { createRemoteRelayRegistry } from './services/remote-relay-registry-service.mjs';
|
|
@@ -68,12 +73,19 @@ import webpush from 'web-push';
|
|
|
68
73
|
import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
|
|
69
74
|
import { createHostSuspendService } from './services/host-suspend-service.mjs';
|
|
70
75
|
import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.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';
|
|
79
|
+
import { createRelayEventService } from './services/relay-event-service.mjs';
|
|
80
|
+
import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
|
|
71
81
|
import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
|
|
72
82
|
import { createWorkerLaunchNoteService } from './services/worker-launch-note-service.mjs';
|
|
73
83
|
import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
|
|
74
84
|
import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
|
|
75
85
|
import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
|
|
76
86
|
import { registerPushRoutes } from './routes/push-routes.mjs';
|
|
87
|
+
import { registerAboutRoutes } from './routes/about-routes.mjs';
|
|
88
|
+
import { createAboutService, resolveBuildCommit } from './services/about-service.mjs';
|
|
77
89
|
import { createImageOperationService } from './services/image-operation-service.mjs';
|
|
78
90
|
import {
|
|
79
91
|
normalizeDriveAbsolutePath as _normalizeDriveAbsolutePath,
|
|
@@ -115,13 +127,24 @@ import {
|
|
|
115
127
|
} from './services/preview-registry-service.mjs';
|
|
116
128
|
import { createPreviewProxyServer } from './services/preview-proxy-server.mjs';
|
|
117
129
|
import { createPreviewHealthProbe } from './services/preview-health-probe.mjs';
|
|
130
|
+
import { createSqlitePreviewStore, isSameHostBoot, readHostBootId } from './services/preview-store.mjs';
|
|
131
|
+
import { validateStaticRootInAny } from './services/preview-static-handler.mjs';
|
|
118
132
|
import { createTunnelWorkerPathGuard } from './services/tunnel-worker-path-guard.mjs';
|
|
133
|
+
import {
|
|
134
|
+
createLocalRequestCheck,
|
|
135
|
+
expressRequestView,
|
|
136
|
+
isSameOriginRequest,
|
|
137
|
+
resolveTunnelMarkerHeaders,
|
|
138
|
+
} from './services/request-boundary.mjs';
|
|
139
|
+
import { createRelayAuth } from './services/relay-auth.mjs';
|
|
140
|
+
import { guardUpgradeSocket } from './services/upgrade-socket-guard.mjs';
|
|
119
141
|
import { createWindowsAutostartService } from './services/windows-autostart-service.mjs';
|
|
120
142
|
import { createWindowsBootAutostartService } from './services/windows-boot-autostart-service.mjs';
|
|
121
143
|
import { createSessionWorkerWebSocketService } from './services/session-worker-websocket-service.mjs';
|
|
122
144
|
import { createTmuxInspectorAccessPolicy } from './services/tmux-inspector-access-policy.mjs';
|
|
123
145
|
import { createTmuxInspectorStreamService } from './services/tmux-inspector-stream-service.mjs';
|
|
124
146
|
import { createTmuxInspectorSocketService } from './services/tmux-inspector-socket-service.mjs';
|
|
147
|
+
import { createSubagentViewerRooms } from './services/subagent-viewer-rooms.mjs';
|
|
125
148
|
import {
|
|
126
149
|
resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
|
|
127
150
|
resolveLaunchWorkspaceRootPath,
|
|
@@ -142,6 +165,24 @@ import {
|
|
|
142
165
|
readTurnCeilingSetting,
|
|
143
166
|
turnCeilingMinutesToMs,
|
|
144
167
|
} from '../shared/turn-ceiling.mjs';
|
|
168
|
+
import {
|
|
169
|
+
SUBAGENT_RECORD_RETENTION_SETTING_KEY,
|
|
170
|
+
parseSubagentRecordRetentionUpdate,
|
|
171
|
+
readSubagentRecordRetentionSetting,
|
|
172
|
+
} from '../shared/subagent-record-retention.mjs';
|
|
173
|
+
import { createSubagentRecordRetention } from './services/subagent-record-retention.mjs';
|
|
174
|
+
import {
|
|
175
|
+
SHARE_PATH_PRIVACY_SETTING_KEY,
|
|
176
|
+
parseSharePathPrivacyUpdate,
|
|
177
|
+
readSharePathPrivacySetting,
|
|
178
|
+
} from '../shared/share-path-privacy.mjs';
|
|
179
|
+
import { isRelayShutdownQueued, queuedShutdownCanProceed, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
|
|
180
|
+
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
181
|
+
import {
|
|
182
|
+
STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
|
|
183
|
+
STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS,
|
|
184
|
+
createStaleSubagentRunSweep,
|
|
185
|
+
} from './services/stale-subagent-run-sweep.mjs';
|
|
145
186
|
import {
|
|
146
187
|
normalizePwaAppName,
|
|
147
188
|
readPwaAppNameSetting,
|
|
@@ -178,6 +219,8 @@ import {
|
|
|
178
219
|
} from '../shared/model-id.mjs';
|
|
179
220
|
import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
|
|
180
221
|
import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
|
|
222
|
+
import { mapSubagentRunHeader } from './services/subagent-timeline-service.mjs';
|
|
223
|
+
import { describeExecError, describeSpawnEnvironment, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
181
224
|
|
|
182
225
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
183
226
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
@@ -195,6 +238,10 @@ const RUNNING_VERSION = (() => {
|
|
|
195
238
|
}
|
|
196
239
|
})();
|
|
197
240
|
const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-checkout' : 'npm-global';
|
|
241
|
+
// When this relay process started, and the commit its code came from (read
|
|
242
|
+
// once: a checkout moved on later still runs the code it started with).
|
|
243
|
+
const RELAY_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
244
|
+
const BUILD_COMMIT = resolveBuildCommit(PACKAGE_ROOT);
|
|
198
245
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
199
246
|
const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
|
|
200
247
|
const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
|
|
@@ -1792,6 +1839,8 @@ const OFFLINE_STALE_RECOVER_MS = 45_000;
|
|
|
1792
1839
|
// reaches the browser.
|
|
1793
1840
|
const SHUTDOWN_SOCKET_FLUSH_MS = 300;
|
|
1794
1841
|
let runtimeShutdownStarted = false;
|
|
1842
|
+
// Set once the database is open (see the retention sweep near the other sweeps).
|
|
1843
|
+
let subagentRecordRetention = null;
|
|
1795
1844
|
const runtimeTimers = {
|
|
1796
1845
|
cliStatus: null,
|
|
1797
1846
|
ownerWatchdog: null,
|
|
@@ -2000,6 +2049,51 @@ function setTurnCeilingMinutes(value) {
|
|
|
2000
2049
|
return { ok: true, ceilingMinutes: parsed.minutes };
|
|
2001
2050
|
}
|
|
2002
2051
|
|
|
2052
|
+
function getSubagentRecordRetentionDays() {
|
|
2053
|
+
return readSubagentRecordRetentionSetting(readAppSettingValue(SUBAGENT_RECORD_RETENTION_SETTING_KEY));
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
function setSubagentRecordRetentionDays(value) {
|
|
2057
|
+
if (typeof stmts?.upsertAppSetting?.run !== 'function') {
|
|
2058
|
+
return { ok: false, error: 'Sub-agent record settings are unavailable' };
|
|
2059
|
+
}
|
|
2060
|
+
const parsed = parseSubagentRecordRetentionUpdate(value);
|
|
2061
|
+
if (!parsed.ok) return parsed;
|
|
2062
|
+
stmts.upsertAppSetting.run(SUBAGENT_RECORD_RETENTION_SETTING_KEY, String(parsed.days), new Date().toISOString());
|
|
2063
|
+
// A shorter period takes effect now, not at the next daily sweep.
|
|
2064
|
+
subagentRecordRetention?.scheduleSweep(5_000);
|
|
2065
|
+
return { ok: true, retentionDays: parsed.days };
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function getSharePathPrivacyMode() {
|
|
2069
|
+
return readSharePathPrivacySetting(readAppSettingValue(SHARE_PATH_PRIVACY_SETTING_KEY));
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
function setSharePathPrivacyMode(value) {
|
|
2073
|
+
if (typeof stmts?.upsertAppSetting?.run !== 'function') {
|
|
2074
|
+
return { ok: false, error: 'Path privacy settings are unavailable' };
|
|
2075
|
+
}
|
|
2076
|
+
const parsed = parseSharePathPrivacyUpdate(value);
|
|
2077
|
+
if (!parsed.ok) return parsed;
|
|
2078
|
+
stmts.upsertAppSetting.run(SHARE_PATH_PRIVACY_SETTING_KEY, parsed.mode, new Date().toISOString());
|
|
2079
|
+
return { ok: true, mode: parsed.mode };
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// The relay host's own roots a shared view must not show: home, the relay's
|
|
2083
|
+
// checkout, data and upload dirs, the workspace roots it knows, and the OS
|
|
2084
|
+
// user name. The share routes add the conversation's own roots.
|
|
2085
|
+
function sharePathKnownRoots() {
|
|
2086
|
+
const roots = [REPO_ROOT, DATA_DIR, UPLOAD_DIR, currentWorkspaceRootPath(), INITIAL_WORKSPACE_ROOT];
|
|
2087
|
+
const userNames = [];
|
|
2088
|
+
try { roots.push(os.homedir()); } catch {}
|
|
2089
|
+
try { userNames.push(os.userInfo().username); } catch {}
|
|
2090
|
+
try { roots.push(resolveDefaultSessionWorkspaceRootState()?.path || ''); } catch {}
|
|
2091
|
+
try { roots.push(...listRecentWorkspaceRoots()); } catch {}
|
|
2092
|
+
try { roots.push(resolveSessionStateRoot?.() || ''); } catch {}
|
|
2093
|
+
roots.push(...workspaceRootAllowList);
|
|
2094
|
+
return { roots: roots.filter(Boolean), userNames: userNames.filter(Boolean) };
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2003
2097
|
const PWA_APP_NAME_SETTING_KEY = 'pwa_app_name';
|
|
2004
2098
|
|
|
2005
2099
|
/** '' means "no custom name": the manifest falls back to the template default. */
|
|
@@ -2470,7 +2564,7 @@ function runCopilotCliCommand(args = [], timeoutMs = 30_000) {
|
|
|
2470
2564
|
if (process.platform === 'win32') {
|
|
2471
2565
|
const escapedArgs = commandArgs.map((part) => `'${String(part).replace(/'/g, "''")}'`).join(' ');
|
|
2472
2566
|
const script = escapedArgs ? `& 'copilot' ${escapedArgs}` : "& 'copilot'";
|
|
2473
|
-
execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, timeout }, (error, stdout, stderr) => {
|
|
2567
|
+
execFile(windowsSystemToolPath('powershell.exe'), ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, timeout }, (error, stdout, stderr) => {
|
|
2474
2568
|
if (error) {
|
|
2475
2569
|
const detail = String(stderr || stdout || error.message || '').trim();
|
|
2476
2570
|
reject(new Error(detail || error.message || 'copilot command failed'));
|
|
@@ -2541,6 +2635,29 @@ const stmts = {
|
|
|
2541
2635
|
...createImageConversationRepository(db),
|
|
2542
2636
|
};
|
|
2543
2637
|
const statusEventService = createStatusEventService(db);
|
|
2638
|
+
// Public shares and their access log (migration 0016).
|
|
2639
|
+
const shareRepository = createShareRepository(db);
|
|
2640
|
+
// Session groups in the sidebar (migration 0014). Without the migration it
|
|
2641
|
+
// sees no groups and writes nothing. What a delete that did not know groups
|
|
2642
|
+
// left behind (a head whose row is gone, a group without a member) is
|
|
2643
|
+
// repaired once at boot.
|
|
2644
|
+
const conversationGroups = createConversationGroupRepository(db);
|
|
2645
|
+
try {
|
|
2646
|
+
const repaired = conversationGroups.repair();
|
|
2647
|
+
if (repaired.headsDetached > 0 || repaired.groupsRemoved > 0) {
|
|
2648
|
+
console.log(`[session-groups] repaired: ${repaired.headsDetached} headless group(s), ${repaired.groupsRemoved} empty group(s) removed`);
|
|
2649
|
+
}
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
console.warn(`[session-groups] repair failed: ${error?.message || error}`);
|
|
2652
|
+
}
|
|
2653
|
+
// Relay events (built further down, next to the usage-limit pause): declared
|
|
2654
|
+
// here so recovery code that runs before then finds null, not a TDZ error.
|
|
2655
|
+
let sessionActivityTracker = null;
|
|
2656
|
+
let relayEventService = null;
|
|
2657
|
+
// Fetches the events of subscriptions on paired relays (built with the
|
|
2658
|
+
// remote relays below; the event service asks it to fetch at once when an
|
|
2659
|
+
// end or a new watcher must reach one).
|
|
2660
|
+
let relayEventLinkService = null;
|
|
2544
2661
|
// Owns the `claude auth …` child processes, so the runtime (not the route
|
|
2545
2662
|
// module) constructs it: shutdownRuntime() has to be able to dispose a login
|
|
2546
2663
|
// PTY that is still holding a process group.
|
|
@@ -2681,7 +2798,7 @@ const imageOperationService = createImageOperationService({
|
|
|
2681
2798
|
|
|
2682
2799
|
modelVariantCatalog.bindDatabase(db);
|
|
2683
2800
|
|
|
2684
|
-
const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot });
|
|
2801
|
+
const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot, conversationGroups });
|
|
2685
2802
|
void deleteArchiveService.retryPendingDeletesOnStartup()
|
|
2686
2803
|
.then((result) => {
|
|
2687
2804
|
if (result?.pendingCount > 0) {
|
|
@@ -2909,8 +3026,13 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
|
|
|
2909
3026
|
}
|
|
2910
3027
|
// An unreadable process list must not stop every launch on the relay
|
|
2911
3028
|
// (2026-10-01: a control byte in one command line did, for an hour).
|
|
3029
|
+
// The environment goes with the first report of a launch only; the pid poll
|
|
3030
|
+
// reads the list up to ten more times.
|
|
3031
|
+
let processListEnvironmentLogged = false;
|
|
2912
3032
|
const warnProcessListUnreadable = (error) => {
|
|
2913
|
-
|
|
3033
|
+
const environment = processListEnvironmentLogged ? '' : `; ${describeSpawnEnvironment(process.env)}`;
|
|
3034
|
+
processListEnvironmentLogged = true;
|
|
3035
|
+
console.warn(`${runtimeLogPrefix()}worker launcher: process list unreadable, launching session=${normalizedTargetSessionId.slice(0, 8)} without the reuse check: ${describeExecError(error)}${environment}`);
|
|
2914
3036
|
};
|
|
2915
3037
|
if (allowProcessReuse) {
|
|
2916
3038
|
let liveWorker = null;
|
|
@@ -2937,6 +3059,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
|
|
|
2937
3059
|
processInspector: sessionWorkerProcessInspector,
|
|
2938
3060
|
allowProcessReuse,
|
|
2939
3061
|
onProcessListError: warnProcessListUnreadable,
|
|
3062
|
+
log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
|
|
2940
3063
|
});
|
|
2941
3064
|
const workerPid = Number.isInteger(Number(launched?.pid)) ? Number(launched.pid) : null;
|
|
2942
3065
|
const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
|
|
@@ -3344,26 +3467,6 @@ function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
|
3344
3467
|
return { cancelled: true, dropped, ...relayShutdownStatePayload() };
|
|
3345
3468
|
}
|
|
3346
3469
|
|
|
3347
|
-
const countPendingRowsByOwnerSession = db.prepare(`
|
|
3348
|
-
SELECT owner_sdk_session_id AS sdk_session_id, COUNT(*) AS n
|
|
3349
|
-
FROM queue
|
|
3350
|
-
WHERE status = 'pending' AND owner_sdk_session_id IS NOT NULL AND owner_sdk_session_id != ''
|
|
3351
|
-
GROUP BY owner_sdk_session_id
|
|
3352
|
-
`);
|
|
3353
|
-
|
|
3354
|
-
function countPendingRowsOfStoppedSessions() {
|
|
3355
|
-
try {
|
|
3356
|
-
let stalled = 0;
|
|
3357
|
-
for (const row of countPendingRowsByOwnerSession.all()) {
|
|
3358
|
-
const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(String(row.sdk_session_id || '').trim());
|
|
3359
|
-
if (lifecycle?.retriesStopped === true) stalled += Number(row.n || 0);
|
|
3360
|
-
}
|
|
3361
|
-
return stalled;
|
|
3362
|
-
} catch {
|
|
3363
|
-
return 0;
|
|
3364
|
-
}
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
3470
|
function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
|
|
3368
3471
|
const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
|
|
3369
3472
|
const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
|
|
@@ -3379,7 +3482,11 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
|
|
|
3379
3482
|
};
|
|
3380
3483
|
}
|
|
3381
3484
|
|
|
3382
|
-
// Shutdown is
|
|
3485
|
+
// Shutdown is deferred until no turn is processing; it never interrupts an
|
|
3486
|
+
// active turn. From here on no pending row is handed out (the dequeue paths
|
|
3487
|
+
// read relayShutdownQueued): a turn started meanwhile would postpone the
|
|
3488
|
+
// restart again. Pending and parked rows wait in the database and run
|
|
3489
|
+
// after the restart.
|
|
3383
3490
|
if (!pendingRelayShutdownRequest) {
|
|
3384
3491
|
pendingRelayShutdownRequest = {
|
|
3385
3492
|
action: requestedAction,
|
|
@@ -3400,12 +3507,7 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
|
|
|
3400
3507
|
const tryShutdownWhenIdle = () => {
|
|
3401
3508
|
if (runtimeShutdownStarted) return;
|
|
3402
3509
|
const counts = queueCounts();
|
|
3403
|
-
|
|
3404
|
-
// retrying makes no progress until somebody presses Retry; it must not
|
|
3405
|
-
// hold the restart (which, fresh, is itself the next launch attempt).
|
|
3406
|
-
const stalledPending = countPendingRowsOfStoppedSessions();
|
|
3407
|
-
const queueFree = (counts.pendingCount - stalledPending + counts.processingCount + counts.parkedCount) <= 0;
|
|
3408
|
-
if (!queueFree) return;
|
|
3510
|
+
if (!queuedShutdownCanProceed(counts)) return;
|
|
3409
3511
|
const requestInfo = pendingRelayShutdownRequest;
|
|
3410
3512
|
pendingRelayShutdownRequest = null;
|
|
3411
3513
|
if (runtimeTimers.shutdownDrain) {
|
|
@@ -3416,7 +3518,7 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
|
|
|
3416
3518
|
const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
|
|
3417
3519
|
console.log(
|
|
3418
3520
|
`${runtimeLogPrefix()}Relay ${action} request accepted by ${requestInfo?.requestedBy || 'unknown'}`
|
|
3419
|
-
+ `;
|
|
3521
|
+
+ `; no turn is processing (${counts.pendingCount} pending, ${counts.parkedCount} parked wait), exiting now (reason=${requestInfo?.reason || 'manual-request'})`,
|
|
3420
3522
|
);
|
|
3421
3523
|
sessionWorkerWebSocketService.emitDraining(`api-${action}:${requestInfo?.reason || 'manual-request'}`);
|
|
3422
3524
|
void shutdownRuntime(`api-${action}:${requestInfo?.reason || 'manual-request'}`, { exitCode });
|
|
@@ -4347,7 +4449,7 @@ function fetchDriveDirectoryEntries(absoluteDirPath, { includeHidden = false } =
|
|
|
4347
4449
|
'$rows | ConvertTo-Json -Depth 4 -Compress',
|
|
4348
4450
|
].join(' ');
|
|
4349
4451
|
|
|
4350
|
-
execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', psScript], { windowsHide: true }, (err, stdout, stderr) => {
|
|
4452
|
+
execFile(windowsSystemToolPath('powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', psScript], { windowsHide: true }, (err, stdout, stderr) => {
|
|
4351
4453
|
if (err) return cb(new Error(`drive list failed: ${stderr || err.message}`));
|
|
4352
4454
|
const text = String(stdout || '').trim();
|
|
4353
4455
|
if (!text) return cb(null, []);
|
|
@@ -4940,6 +5042,9 @@ function createCompactedConversation(sourceConversationId) {
|
|
|
4940
5042
|
stmts.insertConv.run(targetConversationId, nextTitle, now, now);
|
|
4941
5043
|
stmts.updateConvSeed.run(summarySeed, 1, sourceConversationId, now, targetConversationId);
|
|
4942
5044
|
stmts.markConvCompacted.run(targetConversationId, now, sourceConversationId);
|
|
5045
|
+
// The new conversation stays in the old one's session group, and heads
|
|
5046
|
+
// it if the old one did; the archived source stays a member.
|
|
5047
|
+
conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
|
|
4943
5048
|
targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now);
|
|
4944
5049
|
});
|
|
4945
5050
|
tx();
|
|
@@ -5049,19 +5154,18 @@ function subagentRunsForResponse(responseMessageId) {
|
|
|
5049
5154
|
return rows.map(mapSubagentRunRow).filter(Boolean).slice(0, 96);
|
|
5050
5155
|
}
|
|
5051
5156
|
|
|
5157
|
+
// The run as a row of the panel, the reply bubble and the viewer header. Stop
|
|
5158
|
+
// is offered while it runs: a background run through its live task, a
|
|
5159
|
+
// foreground run through the sub-agent cancel route.
|
|
5052
5160
|
function mapSubagentRunRow(row) {
|
|
5053
|
-
const
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
status: String(row?.status || 'running').trim().toLowerCase() || 'running',
|
|
5062
|
-
startedAt: row?.started_at || null,
|
|
5063
|
-
updatedAt: row?.updated_at || null,
|
|
5064
|
-
};
|
|
5161
|
+
const status = String(row?.status || 'running').trim().toLowerCase();
|
|
5162
|
+
let stoppable = false;
|
|
5163
|
+
if (status === 'running') {
|
|
5164
|
+
const tasks = backgroundTaskStore.get(row?.conversation_id);
|
|
5165
|
+
const task = tasks.find((entry) => entry?.toolUseId && entry.toolUseId === String(row?.id || ''));
|
|
5166
|
+
stoppable = task ? task.stoppable !== false : Number(row?.background || 0) !== 1;
|
|
5167
|
+
}
|
|
5168
|
+
return mapSubagentRunHeader(row, { stoppable });
|
|
5065
5169
|
}
|
|
5066
5170
|
|
|
5067
5171
|
// Final workflow digests persisted with an assistant message (the "Finished
|
|
@@ -5100,25 +5204,10 @@ function previewCardsForResponse(responseMessageId) {
|
|
|
5100
5204
|
.slice(0, 5);
|
|
5101
5205
|
}
|
|
5102
5206
|
|
|
5103
|
-
// Live background-task sets
|
|
5104
|
-
//
|
|
5105
|
-
//
|
|
5106
|
-
|
|
5107
|
-
const backgroundTaskStore = {
|
|
5108
|
-
sets: new Map(),
|
|
5109
|
-
replace(conversationId, tasks) {
|
|
5110
|
-
const id = String(conversationId || '').trim();
|
|
5111
|
-
if (!id) return;
|
|
5112
|
-
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
5113
|
-
this.sets.delete(id);
|
|
5114
|
-
return;
|
|
5115
|
-
}
|
|
5116
|
-
this.sets.set(id, { tasks, updatedAt: Date.now() });
|
|
5117
|
-
},
|
|
5118
|
-
get(conversationId) {
|
|
5119
|
-
return this.sets.get(String(conversationId || '').trim())?.tasks || [];
|
|
5120
|
-
},
|
|
5121
|
-
};
|
|
5207
|
+
// Live background-task sets, as the session workers publish them. Memory
|
|
5208
|
+
// only: after a runtime start the heartbeat's digest brings each set back
|
|
5209
|
+
// (services/background-task-store.mjs).
|
|
5210
|
+
const backgroundTaskStore = createBackgroundTaskStore();
|
|
5122
5211
|
|
|
5123
5212
|
// ─── Deferred host suspend ───────────────────────────────────────────────────
|
|
5124
5213
|
// "💤 Suspend host" waits until nothing runs any more: no queued/processing
|
|
@@ -5151,6 +5240,17 @@ function hostActivityTitle(conversationId, fallbackTitle = null) {
|
|
|
5151
5240
|
const title = String(fallbackTitle || stmts.getConv?.get?.(conversationId)?.title || '').trim();
|
|
5152
5241
|
return title || String(conversationId || '').slice(0, 8);
|
|
5153
5242
|
}
|
|
5243
|
+
const backgroundWorkView = createBackgroundWorkView({
|
|
5244
|
+
store: backgroundTaskStore,
|
|
5245
|
+
db,
|
|
5246
|
+
getRegistry: () => sessionWorkerRegistry,
|
|
5247
|
+
getTracker: () => sessionActivityTracker,
|
|
5248
|
+
isWorkerAlive: hostActivityWorkerAlive,
|
|
5249
|
+
sessionIdOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.sdk_session_id,
|
|
5250
|
+
conversationIdOf: (sessionId) => resolveConversationRecord({ conversationId: sessionId, sdkSessionId: sessionId })?.id,
|
|
5251
|
+
providerOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type,
|
|
5252
|
+
titleOf: (conversationId) => hostActivityTitle(conversationId),
|
|
5253
|
+
});
|
|
5154
5254
|
function collectHostActivity({ request = null } = {}) {
|
|
5155
5255
|
const blockers = [];
|
|
5156
5256
|
const turnsByConversation = stmts.listActiveQueueCountsByConversation?.all?.() || [];
|
|
@@ -5174,20 +5274,11 @@ function collectHostActivity({ request = null } = {}) {
|
|
|
5174
5274
|
: (count > 1 ? `${count} turns running` : 'turn running'),
|
|
5175
5275
|
});
|
|
5176
5276
|
}
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
hostSuspendWatchedConversationIds.add(conversationId);
|
|
5183
|
-
const what = tasks.slice(0, 3).map((task) => String(task?.description || task?.taskType || 'task').slice(0, 80)).join('; ');
|
|
5184
|
-
blockers.push({
|
|
5185
|
-
kind: 'background',
|
|
5186
|
-
conversationId,
|
|
5187
|
-
title: hostActivityTitle(conversationId),
|
|
5188
|
-
count: tasks.length,
|
|
5189
|
-
detail: `${tasks.length} background agent${tasks.length === 1 ? '' : 's'}: ${what}`,
|
|
5190
|
-
});
|
|
5277
|
+
// Background work of live workers, and a runtime too young to know of it
|
|
5278
|
+
// (services/background-work-view.mjs).
|
|
5279
|
+
for (const blocker of backgroundWorkView.blockers()) {
|
|
5280
|
+
if (blocker.conversationId) hostSuspendWatchedConversationIds.add(blocker.conversationId);
|
|
5281
|
+
blockers.push(blocker);
|
|
5191
5282
|
}
|
|
5192
5283
|
const runningSubagents = Number(stmts.countRunningSubagentRuns?.get?.()?.cnt || 0);
|
|
5193
5284
|
// CI is only worth a network round-trip for workspaces that were busy since
|
|
@@ -5228,6 +5319,50 @@ const usageLimitPauseService = createUsageLimitPauseService({
|
|
|
5228
5319
|
logger: console,
|
|
5229
5320
|
});
|
|
5230
5321
|
|
|
5322
|
+
// ─── Relay events ─────────────────────────────────────────────────────────────
|
|
5323
|
+
// What each session is doing (state, tasks, quiet time) and the subscriptions
|
|
5324
|
+
// through which a watching session is woken with the events of the sessions
|
|
5325
|
+
// it watches (docs: server/README.md "Relay event subscriptions").
|
|
5326
|
+
sessionActivityTracker = createSessionActivityTracker({
|
|
5327
|
+
db,
|
|
5328
|
+
onEdge: (edge) => relayEventService?.noteTaskEdge(edge),
|
|
5329
|
+
hasUnreportedBackgroundWork: (conversationId) => backgroundWorkView.hasUnreportedWork(conversationId),
|
|
5330
|
+
listUnreportedBackgroundWork: () => backgroundWorkView.listUnreportedConversationIds(),
|
|
5331
|
+
runListedByWorker: (conversationId, row) => backgroundWorkView.runListed(conversationId, row),
|
|
5332
|
+
bootReportingWindowMs: resolveBootReportingWindowMs(process.env, (line) => console.log(`[activity] ${line}`)),
|
|
5333
|
+
logger: console,
|
|
5334
|
+
});
|
|
5335
|
+
relayEventService = createRelayEventService({
|
|
5336
|
+
db,
|
|
5337
|
+
stmts,
|
|
5338
|
+
tracker: sessionActivityTracker,
|
|
5339
|
+
emit: (event, payload) => io.emit(event, payload),
|
|
5340
|
+
// The event row is an ordinary pending message: the primer starts a
|
|
5341
|
+
// stopped worker, the socket service hands it to a running one.
|
|
5342
|
+
wakeWatcher: () => {
|
|
5343
|
+
void primePendingSessionWorkers('relay-event');
|
|
5344
|
+
sessionWorkerWebSocketService?.emitQueueChanged?.('relay-event');
|
|
5345
|
+
},
|
|
5346
|
+
relayName: () => remoteRelayRegistry?.selfName?.() || getPwaAppName?.() || '',
|
|
5347
|
+
onRemoteChange: (relayId) => { void relayEventLinkService?.fetchNow(relayId); },
|
|
5348
|
+
logger: console,
|
|
5349
|
+
});
|
|
5350
|
+
|
|
5351
|
+
/** A worker died: its tasks are gone, and whoever watches its sessions hears of it. */
|
|
5352
|
+
function noteWorkerDeathForEvents(sessionId, conversationIds, reason) {
|
|
5353
|
+
const ids = new Set((conversationIds || []).map((id) => String(id || '').trim()).filter(Boolean));
|
|
5354
|
+
if (!ids.size) {
|
|
5355
|
+
const conversation = resolveConversationRecord({ conversationId: sessionId, sdkSessionId: sessionId });
|
|
5356
|
+
if (conversation?.id) ids.add(String(conversation.id));
|
|
5357
|
+
}
|
|
5358
|
+
for (const conversationId of ids) {
|
|
5359
|
+
try {
|
|
5360
|
+
sessionActivityTracker?.noteWorkerDied(conversationId);
|
|
5361
|
+
relayEventService?.noteError({ conversationId, kind: 'worker_died', message: `the session's worker process ended (${reason})` });
|
|
5362
|
+
} catch {}
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
|
|
5231
5366
|
// The live turn among a conversation's processing rows. Mid-turn steering can
|
|
5232
5367
|
// leave several processing at once — the running turn, steered messages the
|
|
5233
5368
|
// CLI folded into it (no output of their own), a handed-off row still settling
|
|
@@ -5330,6 +5465,9 @@ function failRecoveredRowTerminally(row, reason, { failureRecord = null, failure
|
|
|
5330
5465
|
message: { role: 'assistant', text: failureText, model: requestedModel || 'unknown', mode: String(row?.relay_mode || '').trim() || null, timestamp: now },
|
|
5331
5466
|
});
|
|
5332
5467
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
5468
|
+
try {
|
|
5469
|
+
relayEventService?.noteError({ conversationId: row.conversation_id, kind: 'turn_failed', message: failureText, queueMessageId: row.id });
|
|
5470
|
+
} catch {}
|
|
5333
5471
|
console.warn(`${runtimeLogPrefix()}${failureRecord ? 'CONSUMED FAILED' : 'RECOVERY LIMIT'} ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} retries=${retryCount} reason=${reason}`);
|
|
5334
5472
|
}
|
|
5335
5473
|
|
|
@@ -5354,6 +5492,12 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
|
|
|
5354
5492
|
for (const row of staleContinuations) {
|
|
5355
5493
|
stmts.dropStaleContinuation?.run?.(row.id);
|
|
5356
5494
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
5495
|
+
// A teardown is a turn ending with no reply; it must be visible in the
|
|
5496
|
+
// console log (the only trace of a silent one was `ignored=already_failed`
|
|
5497
|
+
// hours later, when the worker's reply arrived for the dead row).
|
|
5498
|
+
const lastSeen = row.owner_last_claimed_at || row.processing_at || row.timestamp || null;
|
|
5499
|
+
const reason = lastSeen && lastSeen < cutoffIso ? `inactive since ${lastSeen}` : `ceiling (started ${row.processing_at || row.timestamp})`;
|
|
5500
|
+
console.warn(`${runtimeLogPrefix()}STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
|
|
5357
5501
|
}
|
|
5358
5502
|
const listedRows = stmts.listRecoverableProcessing.all(params);
|
|
5359
5503
|
for (const row of listedRows.filter((entry) => entry?.consumed_at)) failConsumedRowTerminally(row, 'stale-recovery');
|
|
@@ -5731,9 +5875,11 @@ const app = express();
|
|
|
5731
5875
|
// Trust boundary for X-Forwarded-* headers. Default 'loopback' matches the
|
|
5732
5876
|
// localhost-first deployment (a cloudflared/caddy tunnel runs on the same host
|
|
5733
5877
|
// and connects over 127.0.0.1), so req.protocol/req.secure/req.ip reflect the
|
|
5734
|
-
// real edge only when the immediate peer is trusted — a direct client
|
|
5735
|
-
//
|
|
5736
|
-
//
|
|
5878
|
+
// real edge only when the immediate peer is trusted — a direct client cannot
|
|
5879
|
+
// forge those headers. Code that needs the client's address, host or scheme
|
|
5880
|
+
// reads req.ip / req.protocol / requestHostOf (services/request-boundary.mjs),
|
|
5881
|
+
// never the raw X-Forwarded-* headers. Operators behind a remote proxy can
|
|
5882
|
+
// widen this via config.trustProxy (e.g. a hop count or subnet).
|
|
5737
5883
|
app.set('trust proxy', config.trustProxy ?? 'loopback');
|
|
5738
5884
|
const httpServer = http.createServer(app);
|
|
5739
5885
|
// Node closes an idle keep-alive connection after 5 s. A client that pools
|
|
@@ -5749,15 +5895,27 @@ httpServer.prependListener('request', (req, _res) => {
|
|
|
5749
5895
|
httpServer.prependListener('upgrade', (req, _socket, _head) => {
|
|
5750
5896
|
rewriteSocketIoRequestPath(req, remotePath);
|
|
5751
5897
|
});
|
|
5898
|
+
// A client reset during an upgrade must not end the relay; see the guard.
|
|
5899
|
+
httpServer.prependListener('upgrade', (_req, socket, _head) => {
|
|
5900
|
+
guardUpgradeSocket(socket);
|
|
5901
|
+
});
|
|
5902
|
+
// "Truly local": a loopback peer with no tunnel marker and no forwarding
|
|
5903
|
+
// header (services/request-boundary.mjs). The marker headers are `cf-ray`
|
|
5904
|
+
// plus config.tunnelMarkerHeaders plus COPILOT_TUNNEL_MARKER_HEADERS, merged:
|
|
5905
|
+
// neither source switches the other off.
|
|
5906
|
+
const localRequestCheck = createLocalRequestCheck({
|
|
5907
|
+
markerHeaders: resolveTunnelMarkerHeaders(
|
|
5908
|
+
config.tunnelMarkerHeaders,
|
|
5909
|
+
process.env.COPILOT_TUNNEL_MARKER_HEADERS,
|
|
5910
|
+
),
|
|
5911
|
+
});
|
|
5752
5912
|
// Public tunnels (Cloudflare, or a VPS/Caddy front) forward every path on the
|
|
5753
5913
|
// bound hostname to this port, including the session-worker WebSocket plumbing.
|
|
5754
|
-
// Reject
|
|
5755
|
-
//
|
|
5914
|
+
// Reject every worker-path request that is not truly local before auth; local
|
|
5915
|
+
// workers connect over 127.0.0.1 without proxy traces and are unaffected.
|
|
5756
5916
|
const tunnelWorkerPathGuard = createTunnelWorkerPathGuard({
|
|
5757
5917
|
pathPrefix: remotePath,
|
|
5758
|
-
|
|
5759
|
-
|| process.env.COPILOT_TUNNEL_MARKER_HEADERS
|
|
5760
|
-
|| [],
|
|
5918
|
+
localRequestCheck,
|
|
5761
5919
|
logger: console,
|
|
5762
5920
|
});
|
|
5763
5921
|
httpServer.prependListener('upgrade', (req, socket, _head) => {
|
|
@@ -5769,8 +5927,27 @@ httpServer.prependListener('upgrade', (req, socket, _head) => {
|
|
|
5769
5927
|
// High-volume stream chunks are emitted volatile and deliberately excluded from
|
|
5770
5928
|
// the replay buffer; see the io.volatile.emit('relay_stream') call site.
|
|
5771
5929
|
const SOCKET_RECOVERY_WINDOW_MS = 10 * 60 * 1000;
|
|
5930
|
+
// The browser app opens its socket on the page's own origin (same host, the
|
|
5931
|
+
// `remotePath` socket.io path), so a handshake from another origin is refused:
|
|
5932
|
+
// a page elsewhere must not ride the relay cookie into a live socket. The
|
|
5933
|
+
// host compared is the one the client addressed (X-Forwarded-Host only from a
|
|
5934
|
+
// trusted proxy); config.allowedOrigins lists extra origins for a proxy that
|
|
5935
|
+
// rewrites the Host header. Clients that send no Origin (Node) pass.
|
|
5936
|
+
const socketAllowedOrigins = Array.isArray(config.allowedOrigins) ? config.allowedOrigins : [];
|
|
5937
|
+
const refusedSocketOrigins = new Set();
|
|
5772
5938
|
const io = new Server(httpServer, {
|
|
5773
|
-
|
|
5939
|
+
allowRequest: (req, callback) => {
|
|
5940
|
+
if (isSameOriginRequest(expressRequestView(app, req), { allowedOrigins: socketAllowedOrigins })) {
|
|
5941
|
+
callback(null, true);
|
|
5942
|
+
return;
|
|
5943
|
+
}
|
|
5944
|
+
const origin = String(req.headers?.origin || '').slice(0, 200);
|
|
5945
|
+
if (refusedSocketOrigins.size < 32 && !refusedSocketOrigins.has(origin)) {
|
|
5946
|
+
refusedSocketOrigins.add(origin);
|
|
5947
|
+
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`);
|
|
5948
|
+
}
|
|
5949
|
+
callback('Origin not allowed', false);
|
|
5950
|
+
},
|
|
5774
5951
|
path: socketIoPath(),
|
|
5775
5952
|
connectionStateRecovery: {
|
|
5776
5953
|
maxDisconnectionDuration: SOCKET_RECOVERY_WINDOW_MS,
|
|
@@ -5958,6 +6135,9 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
5958
6135
|
if (runtimeState.relayPaused) {
|
|
5959
6136
|
return { message: null, paused: true, reason: 'relay_paused' };
|
|
5960
6137
|
}
|
|
6138
|
+
if (isRelayShutdownQueued(runtimeState)) {
|
|
6139
|
+
return relayShutdownGateReply(runtimeState);
|
|
6140
|
+
}
|
|
5961
6141
|
if (runtimeState.tunnelState?.blocking) {
|
|
5962
6142
|
return {
|
|
5963
6143
|
message: null,
|
|
@@ -6172,11 +6352,36 @@ async function isWorkerSessionProcessAlive(sessionId, hintPid = null) {
|
|
|
6172
6352
|
return false;
|
|
6173
6353
|
}
|
|
6174
6354
|
|
|
6355
|
+
// Runs a dead worker can no longer end: marked failed, each with the full
|
|
6356
|
+
// header and its own turn's messageId, so an open page settles the row
|
|
6357
|
+
// without a reload (the browser drops a status event it cannot place).
|
|
6358
|
+
function closeStrandedSubagentRuns(runs, queueMessageId = null) {
|
|
6359
|
+
if (!runs.length) return;
|
|
6360
|
+
const nowIso = new Date().toISOString();
|
|
6361
|
+
for (const run of runs) {
|
|
6362
|
+
stmts.updateSubagentRunStatus?.run?.('failed', nowIso, nowIso, run.id);
|
|
6363
|
+
const closed = stmts.getSubagentRun?.get?.(run.id) || { ...run, status: 'failed', completed_at: nowIso };
|
|
6364
|
+
const header = mapSubagentRunHeader(closed, { stoppable: false });
|
|
6365
|
+
if (header) {
|
|
6366
|
+
io.emit('subagent_status', {
|
|
6367
|
+
...header,
|
|
6368
|
+
timestamp: nowIso,
|
|
6369
|
+
queueMessageId: queueMessageId || header.messageId,
|
|
6370
|
+
reconciled: true,
|
|
6371
|
+
});
|
|
6372
|
+
}
|
|
6373
|
+
}
|
|
6374
|
+
}
|
|
6375
|
+
|
|
6175
6376
|
function recoverDeadWorkerProcessingRows(sessionId, reason) {
|
|
6176
6377
|
const sid = String(sessionId || '').trim();
|
|
6177
6378
|
if (!sid) return 0;
|
|
6379
|
+
// A background agent outlives its turn, so its run is closed with the
|
|
6380
|
+
// worker rather than with a processing row (its turn is usually done).
|
|
6381
|
+
closeStrandedSubagentRuns(stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || []);
|
|
6178
6382
|
const rows = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
6179
6383
|
if (!rows.length) return 0;
|
|
6384
|
+
noteWorkerDeathForEvents(sid, rows.map((row) => row.conversation_id), reason);
|
|
6180
6385
|
let recovered = 0;
|
|
6181
6386
|
const requeueAt = addMsIso(2_000);
|
|
6182
6387
|
for (const row of rows) {
|
|
@@ -6196,22 +6401,7 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
|
|
|
6196
6401
|
}
|
|
6197
6402
|
// The dead worker's subagent runs can never be terminated by the replay
|
|
6198
6403
|
// (fresh worker, fresh call ids) — close them out now.
|
|
6199
|
-
|
|
6200
|
-
if (strandedRuns.length) {
|
|
6201
|
-
const nowIso = new Date().toISOString();
|
|
6202
|
-
stmts.closeRunningSubagentRunsByQueueMessage?.run?.('failed', nowIso, nowIso, row.id);
|
|
6203
|
-
for (const run of strandedRuns) {
|
|
6204
|
-
io.emit('subagent_status', {
|
|
6205
|
-
conversationId: run.conversation_id,
|
|
6206
|
-
subagentRunId: run.id,
|
|
6207
|
-
parentSubagentId: run.parent_subagent_id || null,
|
|
6208
|
-
displayName: run.display_name || null,
|
|
6209
|
-
status: 'failed',
|
|
6210
|
-
queueMessageId: row.id,
|
|
6211
|
-
reconciled: true,
|
|
6212
|
-
});
|
|
6213
|
-
}
|
|
6214
|
-
}
|
|
6404
|
+
closeStrandedSubagentRuns(stmts.listRunningSubagentRunsByQueueMessage?.all?.(row.id) || [], row.id);
|
|
6215
6405
|
stmts.recoverProcessingRowKeepOwner?.run?.(requeueAt, row.id);
|
|
6216
6406
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
6217
6407
|
recovered += 1;
|
|
@@ -6231,18 +6421,27 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
|
|
|
6231
6421
|
if (runtimeShutdownStarted) return;
|
|
6232
6422
|
const sid = String(sessionId || '').trim();
|
|
6233
6423
|
if (!sid) return;
|
|
6424
|
+
detachedWorkerWatch.noteSocketEnded(sid);
|
|
6234
6425
|
const owned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
6235
6426
|
// A dead worker's steering snapshot must not keep holding the composer even
|
|
6236
6427
|
// when it owned no rows (the hold can outlive them, e.g. a continuation's).
|
|
6237
6428
|
const heldSteering = Boolean(sessionWorkerRegistry?.getWorker?.(sid)?.steering);
|
|
6238
|
-
|
|
6429
|
+
// Background agents run between turns too: a worker that dies then owns no
|
|
6430
|
+
// row, but its agents' runs still read "running".
|
|
6431
|
+
const backgroundRuns = () => stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || [];
|
|
6432
|
+
if (!owned.length && !heldSteering && !backgroundRuns().length) return;
|
|
6239
6433
|
const timer = setTimeout(async () => {
|
|
6240
6434
|
try {
|
|
6241
6435
|
if (runtimeShutdownStarted) return;
|
|
6242
6436
|
if (await isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
|
|
6243
6437
|
sessionWorkerRegistry?.clearSteering?.(sid);
|
|
6244
6438
|
const stillOwned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
6245
|
-
if (!stillOwned.length)
|
|
6439
|
+
if (!stillOwned.length) {
|
|
6440
|
+
const stranded = backgroundRuns();
|
|
6441
|
+
closeStrandedSubagentRuns(stranded);
|
|
6442
|
+
if (stranded.length) noteWorkerDeathForEvents(sid, stranded.map((run) => run.conversation_id), 'socket-close');
|
|
6443
|
+
return;
|
|
6444
|
+
}
|
|
6246
6445
|
console.warn(`${runtimeLogPrefix()}WORKER DIED session=${sid.slice(0, 8)} rows=${stillOwned.length} trigger=socket-close — recovering owned turns`);
|
|
6247
6446
|
recoverDeadWorkerProcessingRows(sid, 'socket-close');
|
|
6248
6447
|
} catch (error) {
|
|
@@ -6280,6 +6479,64 @@ async function sweepDeadWorkerProcessingRowsOnce() {
|
|
|
6280
6479
|
return recovered;
|
|
6281
6480
|
}
|
|
6282
6481
|
|
|
6482
|
+
// A worker alive without its socket while a message waits: replaced after a
|
|
6483
|
+
// grace time (see the service). Stops the way the kill route does, but only
|
|
6484
|
+
// names the registry's pid when the process list confirms it is the
|
|
6485
|
+
// session's worker, then spawns the replacement.
|
|
6486
|
+
async function replaceDetachedSessionWorker(sid) {
|
|
6487
|
+
const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
|
|
6488
|
+
const found = process.platform === 'win32'
|
|
6489
|
+
? await sessionWorkerProcessInspector.findWindowsProcessTreeForSessionAsync(sid, { fresh: true })
|
|
6490
|
+
: await sessionWorkerProcessInspector.findProcessesForSessionAsync(sid, { fresh: true });
|
|
6491
|
+
const foundPids = new Set((found || []).map((entry) => Number(entry?.processId)));
|
|
6492
|
+
const stopped = await stopSessionWorkerProcesses({
|
|
6493
|
+
sdkSessionId: sid,
|
|
6494
|
+
worker: worker && foundPids.has(Number(worker.pid)) ? worker : null,
|
|
6495
|
+
processInspector: sessionWorkerProcessInspector,
|
|
6496
|
+
isPidAliveImpl: isProcessAlive,
|
|
6497
|
+
killTmuxSessionImpl: killTmuxSession,
|
|
6498
|
+
});
|
|
6499
|
+
if (!stopped.ok) return { ok: false, error: stopped.error || 'worker-survived-stop' };
|
|
6500
|
+
sessionWorkerRegistry?.removeWorker?.(sid);
|
|
6501
|
+
sessionWorkerSupervisor?.clearRestartSchedule?.(sid);
|
|
6502
|
+
sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
|
|
6503
|
+
const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid);
|
|
6504
|
+
return { ok: launched?.ok === true, error: launched?.error || null };
|
|
6505
|
+
}
|
|
6506
|
+
|
|
6507
|
+
function sessionHasBackgroundWork(sid) {
|
|
6508
|
+
if ((stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || []).length) return true;
|
|
6509
|
+
if (backgroundWorkView.hasUnreportedWork(sid)) return true;
|
|
6510
|
+
const conversationId = resolveConversationRecord({ conversationId: sid, sdkSessionId: sid })?.id || '';
|
|
6511
|
+
if (backgroundTaskStore.get(sid).length || (conversationId && backgroundTaskStore.get(conversationId).length)) return true;
|
|
6512
|
+
return backgroundWorkView.backgroundBlockers()
|
|
6513
|
+
.some((blocker) => blocker.conversationId === sid || (conversationId && blocker.conversationId === conversationId));
|
|
6514
|
+
}
|
|
6515
|
+
|
|
6516
|
+
const detachedWorkerWatch = createDetachedWorkerWatch({
|
|
6517
|
+
isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
|
|
6518
|
+
&& !runtimeShutdownStarted
|
|
6519
|
+
&& !pendingRelayShutdownRequest,
|
|
6520
|
+
listWaitingSessionIds: () => (stmts.listPendingWorkerOwnerSessionIds?.all?.(25) || [])
|
|
6521
|
+
.map((row) => row?.sdk_session_id),
|
|
6522
|
+
hasWorkerSocket: (sid) => sessionWorkerWebSocketService.hasWorkerSocket(sid) === true,
|
|
6523
|
+
hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
|
|
6524
|
+
hasBackgroundWork: sessionHasBackgroundWork,
|
|
6525
|
+
isLaunchBlocked: (sid) => sessionWorkerSupervisor?.isKillBlocked?.(sid) === true,
|
|
6526
|
+
getLaunchState: (sid) => {
|
|
6527
|
+
const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(sid) || null;
|
|
6528
|
+
if (!lifecycle) return null;
|
|
6529
|
+
return {
|
|
6530
|
+
launchMode: lifecycle.launchMode,
|
|
6531
|
+
launchAtMs: lifecycle.launchAt ? Date.parse(lifecycle.launchAt) : null,
|
|
6532
|
+
awaitingHeartbeat: lifecycle.awaitingHeartbeat === true,
|
|
6533
|
+
};
|
|
6534
|
+
},
|
|
6535
|
+
isWorkerProcessAlive: async (sid) => Boolean(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid)),
|
|
6536
|
+
replaceWorker: replaceDetachedSessionWorker,
|
|
6537
|
+
log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
|
|
6538
|
+
});
|
|
6539
|
+
|
|
6283
6540
|
const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
|
|
6284
6541
|
WebSocketServerImpl: WebSocketServer,
|
|
6285
6542
|
httpServer,
|
|
@@ -6414,7 +6671,27 @@ app.get(['/shared/:token', '/shared/:token/'], (req, res, next) => {
|
|
|
6414
6671
|
try {
|
|
6415
6672
|
const rawToken = String(req.params?.token || '').trim().toLowerCase();
|
|
6416
6673
|
const shareToken = /^[a-f0-9]{32,128}$/.test(rawToken) ? rawToken : '';
|
|
6674
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
6675
|
+
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
6676
|
+
// An unknown, revoked or renewed link is a plain 404: no app shell, and
|
|
6677
|
+
// nothing that tells it apart from a link that never existed.
|
|
6678
|
+
if (!shareToken || !shareRepository.findLiveShare(shareToken)) {
|
|
6679
|
+
res.status(404).type('text/plain').send('Not Found');
|
|
6680
|
+
return;
|
|
6681
|
+
}
|
|
6417
6682
|
const html = renderIndexHtmlWithPwaVersion({ sharedToken: shareToken });
|
|
6683
|
+
res.type('html').send(html);
|
|
6684
|
+
} catch (error) {
|
|
6685
|
+
next(error);
|
|
6686
|
+
}
|
|
6687
|
+
});
|
|
6688
|
+
// "Preview as shared": the shared reader for the owner. The page holds
|
|
6689
|
+
// nothing of the conversation; its data comes from the owner-only preview route.
|
|
6690
|
+
app.get(['/share-preview/:conversationId', '/share-preview/:conversationId/'], (req, res, next) => {
|
|
6691
|
+
try {
|
|
6692
|
+
const conversationId = String(req.params?.conversationId || '').trim();
|
|
6693
|
+
const sharePreviewConversationId = /^[A-Za-z0-9_.:-]{1,200}$/.test(conversationId) ? conversationId : '';
|
|
6694
|
+
const html = renderIndexHtmlWithPwaVersion({ sharePreviewConversationId });
|
|
6418
6695
|
res.setHeader('Cache-Control', 'no-store');
|
|
6419
6696
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
6420
6697
|
res.type('html').send(html);
|
|
@@ -6437,6 +6714,9 @@ const runtimeState = {
|
|
|
6437
6714
|
set relayPaused(value) { relayPaused = value; },
|
|
6438
6715
|
get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
|
|
6439
6716
|
get relayShutdown() { return getRelayShutdownState(); },
|
|
6717
|
+
// A shutdown or restart waits for the processing turns: no pending row
|
|
6718
|
+
// goes out meanwhile (see requestRelayShutdown).
|
|
6719
|
+
get relayShutdownQueued() { return !!pendingRelayShutdownRequest && !runtimeShutdownStarted; },
|
|
6440
6720
|
get hostSuspend() { return hostSuspendService.getState(); },
|
|
6441
6721
|
get usageLimit() {
|
|
6442
6722
|
return { account: usageLimitPauseService.getAccountState(), pauses: usageLimitPauseService.listPauses() };
|
|
@@ -6490,6 +6770,13 @@ const remoteRelayRegistry = createRemoteRelayRegistry({
|
|
|
6490
6770
|
platform: process.platform,
|
|
6491
6771
|
version: RUNNING_VERSION,
|
|
6492
6772
|
emit: (event, payload) => io.emit(event, payload),
|
|
6773
|
+
onRemove: (entry) => { void relayEventLinkService?.noteRelayRemoved(entry); },
|
|
6774
|
+
logger: console,
|
|
6775
|
+
});
|
|
6776
|
+
relayEventLinkService = createRelayEventLinkService({
|
|
6777
|
+
relayEvents: relayEventService,
|
|
6778
|
+
registry: remoteRelayRegistry,
|
|
6779
|
+
client: remoteRelayClient,
|
|
6493
6780
|
logger: console,
|
|
6494
6781
|
});
|
|
6495
6782
|
const remoteRelayPairing = createRemoteRelayPairing({
|
|
@@ -6520,7 +6807,10 @@ const remoteRelayCallerContext = createRemoteRelayCallerContext({
|
|
|
6520
6807
|
normalizeRelayMode,
|
|
6521
6808
|
defaultRelayMode: DEFAULT_RELAY_MODE,
|
|
6522
6809
|
emit: (event, payload) => io.emit(event, payload),
|
|
6523
|
-
notifyQuestion: (question) =>
|
|
6810
|
+
notifyQuestion: (question) => {
|
|
6811
|
+
try { relayEventService?.noteQuestion(question); } catch {}
|
|
6812
|
+
return pushDispatchService.notifyQuestion?.(question);
|
|
6813
|
+
},
|
|
6524
6814
|
uuid: uuidv4,
|
|
6525
6815
|
logger: console,
|
|
6526
6816
|
});
|
|
@@ -6541,11 +6831,14 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
|
|
|
6541
6831
|
requestApproval: (request) => remoteRelayCallerContext.requestApproval(request),
|
|
6542
6832
|
emitStatusEvent: ({ type, ...details }) => statusEventService.recordEvent(type, details),
|
|
6543
6833
|
resolveConversationId: (id) => resolveConversationRecord({ conversationId: id, sdkSessionId: id })?.id || id,
|
|
6834
|
+
relayEvents: relayEventService,
|
|
6835
|
+
relayEventLink: relayEventLinkService,
|
|
6544
6836
|
logger: console,
|
|
6545
6837
|
});
|
|
6546
6838
|
|
|
6547
6839
|
const sharedRouteDeps = {
|
|
6548
6840
|
auth,
|
|
6841
|
+
isTrulyLocalRequest: localRequestCheck.isTrulyLocal,
|
|
6549
6842
|
gitChangesService,
|
|
6550
6843
|
gitRemoteService,
|
|
6551
6844
|
currentWorkspaceRootPath,
|
|
@@ -6665,9 +6958,12 @@ const sharedRouteDeps = {
|
|
|
6665
6958
|
inFlightStateForConversation,
|
|
6666
6959
|
resolveLiveTurnQueueRow,
|
|
6667
6960
|
backgroundTaskStore,
|
|
6961
|
+
backgroundWorkView,
|
|
6668
6962
|
// A closure, not the registry itself: this deps object is built before the
|
|
6669
6963
|
// preview lane is constructed further down the file.
|
|
6670
6964
|
listConversationPreviews: (conversationId) => previewRegistry.listForConversation(conversationId),
|
|
6965
|
+
closeConversationPreviews: (conversationId) => previewRegistry.closeForConversation(conversationId),
|
|
6966
|
+
listKnownPreviewTokens: () => previewRegistry.knownTokens(),
|
|
6671
6967
|
sendWorkerControl: (sessionId, control) => sessionWorkerWebSocketService.sendControlToSession(sessionId, control),
|
|
6672
6968
|
emitToClientsExceptSessionId,
|
|
6673
6969
|
relayBridgeOwnerService,
|
|
@@ -6729,6 +7025,11 @@ const sharedRouteDeps = {
|
|
|
6729
7025
|
resolveCursorSessionRoot: cursorSessionRootResolver.resolveCursorSessionRoot,
|
|
6730
7026
|
getTurnCeilingMinutes,
|
|
6731
7027
|
setTurnCeilingMinutes,
|
|
7028
|
+
getSubagentRecordRetentionDays,
|
|
7029
|
+
setSubagentRecordRetentionDays,
|
|
7030
|
+
getSharePathPrivacyMode,
|
|
7031
|
+
setSharePathPrivacyMode,
|
|
7032
|
+
sharePathKnownRoots,
|
|
6732
7033
|
getBackgroundTaskTimeoutMinutes,
|
|
6733
7034
|
setBackgroundTaskTimeoutMinutes,
|
|
6734
7035
|
getFeatureFlagsSettingsState,
|
|
@@ -6741,6 +7042,9 @@ const sharedRouteDeps = {
|
|
|
6741
7042
|
hostSuspendService,
|
|
6742
7043
|
collectHostActivity,
|
|
6743
7044
|
usageLimitPauseService,
|
|
7045
|
+
relayEventService,
|
|
7046
|
+
sessionActivityTracker,
|
|
7047
|
+
resolveConversationRecord,
|
|
6744
7048
|
markSharedViewerPresence,
|
|
6745
7049
|
getSharedWatcherCount,
|
|
6746
7050
|
statusEventService,
|
|
@@ -6756,9 +7060,12 @@ const sharedRouteDeps = {
|
|
|
6756
7060
|
windowsBootAutostartService,
|
|
6757
7061
|
pushDispatchService,
|
|
6758
7062
|
remoteRelayInbound,
|
|
7063
|
+
conversationGroups,
|
|
7064
|
+
shareRepository,
|
|
6759
7065
|
};
|
|
6760
7066
|
registerMessagesRoutes(app, sharedRouteDeps);
|
|
6761
7067
|
registerSessionsRoutes(app, sharedRouteDeps);
|
|
7068
|
+
registerConversationGroupRoutes(app, sharedRouteDeps);
|
|
6762
7069
|
registerAskUserRoutes(app, sharedRouteDeps);
|
|
6763
7070
|
registerRelayBoardRoutes(app, sharedRouteDeps);
|
|
6764
7071
|
registerCacheRoutes(app, sharedRouteDeps);
|
|
@@ -6774,6 +7081,36 @@ registerRemoteRelayRoutes(app, {
|
|
|
6774
7081
|
registry: remoteRelayRegistry,
|
|
6775
7082
|
pairing: remoteRelayPairing,
|
|
6776
7083
|
dispatcher: remoteRelayDispatcher,
|
|
7084
|
+
remoteRelayInbound,
|
|
7085
|
+
});
|
|
7086
|
+
registerRelayEventRoutes(app, {
|
|
7087
|
+
auth,
|
|
7088
|
+
relayEventService,
|
|
7089
|
+
remoteRelayInbound,
|
|
7090
|
+
});
|
|
7091
|
+
registerAboutRoutes(app, {
|
|
7092
|
+
auth,
|
|
7093
|
+
aboutService: createAboutService({
|
|
7094
|
+
db,
|
|
7095
|
+
dbPath: DB_PATH,
|
|
7096
|
+
version: RUNNING_VERSION,
|
|
7097
|
+
commit: BUILD_COMMIT,
|
|
7098
|
+
installMethod: INSTALL_METHOD,
|
|
7099
|
+
startedAt: RELAY_STARTED_AT,
|
|
7100
|
+
packageRoot: PACKAGE_ROOT,
|
|
7101
|
+
getProviderSettings: () => ({
|
|
7102
|
+
github: { enabled: true, engine: getCopilotEngine() },
|
|
7103
|
+
openai: { enabled: getOpenAIProviderSettings()?.enabled === true },
|
|
7104
|
+
claude: { enabled: getClaudeProviderSettings()?.enabled === true },
|
|
7105
|
+
'claude-cloud': { enabled: getClaudeCloudProviderSettings()?.enabled === true },
|
|
7106
|
+
cursor: { enabled: getCursorProviderSettings()?.enabled === true },
|
|
7107
|
+
grok: { enabled: getGrokProviderSettings()?.enabled === true },
|
|
7108
|
+
}),
|
|
7109
|
+
getCachedCliProviders: () => cliInstallService.getCachedStatusSnapshot()?.providers || {},
|
|
7110
|
+
isRelayCliOnline: () => runtimeState.cliOnline === true,
|
|
7111
|
+
listWorkers: () => sessionWorkerRegistry.listWorkers(),
|
|
7112
|
+
getUpdateState: () => buildUpdateStatePayload(),
|
|
7113
|
+
}),
|
|
6777
7114
|
});
|
|
6778
7115
|
function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
6779
7116
|
cliLastSeen = null;
|
|
@@ -6817,9 +7154,22 @@ if (managedOwnerPid) {
|
|
|
6817
7154
|
// The window is measured from the last heartbeat that named the message, not
|
|
6818
7155
|
// from when the turn began, so a turn stays alive for as long as its worker
|
|
6819
7156
|
// keeps reporting it — however many minutes of tool calls and subagents that
|
|
6820
|
-
// takes. The separate, user-configurable ceiling is the only elapsed-time cap
|
|
6821
|
-
//
|
|
7157
|
+
// takes. The separate, user-configurable ceiling is the only elapsed-time cap;
|
|
7158
|
+
// it counts from the last answered question rather than from the start, and
|
|
7159
|
+
// both exempt a turn that is waiting on an unanswered question (continuation
|
|
7160
|
+
// rows included — an interview hangs its cards on one).
|
|
7161
|
+
//
|
|
7162
|
+
// After a wall-clock jump (the host slept, the event loop was blocked) every
|
|
7163
|
+
// heartbeat looks old at once although the workers may be alive: the guard
|
|
7164
|
+
// skips recovery for a full inactivity window, so a live worker can report.
|
|
7165
|
+
const STALE_RECOVERY_INTERVAL_MS = 15_000;
|
|
7166
|
+
const staleRecoveryClockGuard = createStaleRecoveryClockGuard({
|
|
7167
|
+
intervalMs: STALE_RECOVERY_INTERVAL_MS,
|
|
7168
|
+
graceMs: Math.max(processingTimeoutMs, 60_000),
|
|
7169
|
+
log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
|
|
7170
|
+
});
|
|
6822
7171
|
function recoverStaleMessages() {
|
|
7172
|
+
if (staleRecoveryClockGuard.tick().skip) return;
|
|
6823
7173
|
const staleWindowMs = cliOnline
|
|
6824
7174
|
? processingTimeoutMs
|
|
6825
7175
|
: Math.min(processingTimeoutMs, OFFLINE_STALE_RECOVER_MS);
|
|
@@ -6835,7 +7185,7 @@ function recoverStaleMessages() {
|
|
|
6835
7185
|
);
|
|
6836
7186
|
}
|
|
6837
7187
|
}
|
|
6838
|
-
runtimeTimers.staleRecovery = setInterval(recoverStaleMessages,
|
|
7188
|
+
runtimeTimers.staleRecovery = setInterval(recoverStaleMessages, STALE_RECOVERY_INTERVAL_MS);
|
|
6839
7189
|
recoverStaleMessages(); // run immediately on startup
|
|
6840
7190
|
|
|
6841
7191
|
function expirePendingQuestions() {
|
|
@@ -6865,6 +7215,40 @@ if (sweptUploads > 0) {
|
|
|
6865
7215
|
}
|
|
6866
7216
|
runtimeTimers.uploadSweep = setInterval(sweepUnreferencedUploadBlobs, 6 * 60 * 60 * 1000);
|
|
6867
7217
|
if (typeof runtimeTimers.uploadSweep.unref === 'function') runtimeTimers.uploadSweep.unref();
|
|
7218
|
+
// Old sub-agent records lose their tool inputs and output: shortly after
|
|
7219
|
+
// start (not in the boot path), then once a day.
|
|
7220
|
+
subagentRecordRetention = createSubagentRecordRetention({
|
|
7221
|
+
db,
|
|
7222
|
+
getRetentionDays: getSubagentRecordRetentionDays,
|
|
7223
|
+
isStopped: () => runtimeShutdownStarted,
|
|
7224
|
+
log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
|
|
7225
|
+
warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
|
|
7226
|
+
});
|
|
7227
|
+
subagentRecordRetention.scheduleSweep(30_000);
|
|
7228
|
+
runtimeTimers.subagentRecordRetention = setInterval(() => { void subagentRecordRetention.sweep(); }, 24 * 60 * 60 * 1000);
|
|
7229
|
+
if (typeof runtimeTimers.subagentRecordRetention.unref === 'function') runtimeTimers.subagentRecordRetention.unref();
|
|
7230
|
+
// Sub-agent runs left `running` by a worker that is gone are closed: once
|
|
7231
|
+
// the previous runtime's workers have had time to reconnect after boot, then
|
|
7232
|
+
// periodically. A live worker's runs are never touched.
|
|
7233
|
+
const staleSubagentRunSweep = createStaleSubagentRunSweep({
|
|
7234
|
+
db,
|
|
7235
|
+
isConversationWorkerAlive: (conversationId) => {
|
|
7236
|
+
if (hostActivityWorkerAlive(conversationId)) return true;
|
|
7237
|
+
const sdkSessionId = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.sdk_session_id || '').trim();
|
|
7238
|
+
return !!sdkSessionId && hostActivityWorkerAlive(sdkSessionId);
|
|
7239
|
+
},
|
|
7240
|
+
log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
|
|
7241
|
+
warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
|
|
7242
|
+
});
|
|
7243
|
+
runtimeTimers.staleSubagentRunsBoot = setTimeout(() => {
|
|
7244
|
+
runtimeTimers.staleSubagentRunsBoot = null;
|
|
7245
|
+
if (!runtimeShutdownStarted) staleSubagentRunSweep.sweep();
|
|
7246
|
+
}, STALE_SUBAGENT_RUN_BOOT_DELAY_MS);
|
|
7247
|
+
if (typeof runtimeTimers.staleSubagentRunsBoot.unref === 'function') runtimeTimers.staleSubagentRunsBoot.unref();
|
|
7248
|
+
runtimeTimers.staleSubagentRuns = setInterval(() => {
|
|
7249
|
+
if (!runtimeShutdownStarted) staleSubagentRunSweep.sweep();
|
|
7250
|
+
}, STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS);
|
|
7251
|
+
if (typeof runtimeTimers.staleSubagentRuns.unref === 'function') runtimeTimers.staleSubagentRuns.unref();
|
|
6868
7252
|
// Arms the 12h update poller only when the user opted in; otherwise silent.
|
|
6869
7253
|
updateCheckService?.startIfEnabled();
|
|
6870
7254
|
const runtimeBindingsBootstrapped = bootstrapRuntimeSessionBindings();
|
|
@@ -6878,38 +7262,21 @@ function runtimeLogPrefix() {
|
|
|
6878
7262
|
}
|
|
6879
7263
|
|
|
6880
7264
|
// ─── Auth Middleware ──────────────────────────────────────────────────────────
|
|
6881
|
-
//
|
|
6882
|
-
//
|
|
6883
|
-
//
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
7265
|
+
// Token check, wrong-token lockout and the Socket.IO handshake gate live in
|
|
7266
|
+
// services/relay-auth.mjs. `auth` stays a hoisted function: the route deps
|
|
7267
|
+
// above hand it out before this line runs.
|
|
7268
|
+
const relayAuth = createRelayAuth({
|
|
7269
|
+
app,
|
|
7270
|
+
getAuthToken: () => config.authToken,
|
|
7271
|
+
cookieName: AUTH_COOKIE,
|
|
7272
|
+
cookiePath: COOKIE_PATH,
|
|
7273
|
+
parseCookies,
|
|
7274
|
+
appendSetCookie,
|
|
7275
|
+
localRequestCheck,
|
|
7276
|
+
logger: console,
|
|
7277
|
+
});
|
|
6890
7278
|
function auth(req, res, next) {
|
|
6891
|
-
|
|
6892
|
-
// req.secure is derived through the configured `trust proxy` boundary, so a
|
|
6893
|
-
// direct (untrusted) client can no longer force a non-Secure cookie by
|
|
6894
|
-
// omitting X-Forwarded-Proto.
|
|
6895
|
-
const secureAttr = req.secure ? '; Secure' : '';
|
|
6896
|
-
// Note: the token is intentionally NOT read from req.body — a request body is
|
|
6897
|
-
// an easy CSRF/logging vector and has no legitimate use here. Header, query
|
|
6898
|
-
// (first-load bootstrap only), and the HttpOnly cookie remain.
|
|
6899
|
-
const token =
|
|
6900
|
-
(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') ||
|
|
6901
|
-
req.query.token ||
|
|
6902
|
-
cookies[AUTH_COOKIE];
|
|
6903
|
-
if (tokensMatch(token, config.authToken)) {
|
|
6904
|
-
if (res && !tokensMatch(cookies[AUTH_COOKIE], config.authToken)) {
|
|
6905
|
-
appendSetCookie(res, `${AUTH_COOKIE}=${encodeURIComponent(config.authToken)}; Path=${COOKIE_PATH}; Max-Age=2592000; SameSite=Lax; HttpOnly${secureAttr}`);
|
|
6906
|
-
}
|
|
6907
|
-
return next();
|
|
6908
|
-
}
|
|
6909
|
-
if (res && cookies[AUTH_COOKIE]) {
|
|
6910
|
-
appendSetCookie(res, `${AUTH_COOKIE}=; Path=${COOKIE_PATH}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; HttpOnly${secureAttr}`);
|
|
6911
|
-
}
|
|
6912
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
7279
|
+
return relayAuth.auth(req, res, next);
|
|
6913
7280
|
}
|
|
6914
7281
|
|
|
6915
7282
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -7067,8 +7434,15 @@ function touchCli() {
|
|
|
7067
7434
|
}
|
|
7068
7435
|
sessionWorkerWebSocketService.start();
|
|
7069
7436
|
void primePendingSessionWorkers('startup');
|
|
7437
|
+
// Undelivered events get their timers back; stalled, all_idle and the
|
|
7438
|
+
// heartbeat are swept from here on.
|
|
7439
|
+
relayEventService.start();
|
|
7440
|
+
relayEventLinkService.start();
|
|
7070
7441
|
runtimeTimers.pendingWorkerPrime = setInterval(() => {
|
|
7071
7442
|
void primePendingSessionWorkers('maintenance');
|
|
7443
|
+
detachedWorkerWatch.sweep().catch((error) => {
|
|
7444
|
+
console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
|
|
7445
|
+
});
|
|
7072
7446
|
}, 5_000);
|
|
7073
7447
|
if (typeof runtimeTimers.pendingWorkerPrime.unref === 'function') runtimeTimers.pendingWorkerPrime.unref();
|
|
7074
7448
|
|
|
@@ -7109,14 +7483,13 @@ function sanitizeRelayQuestionContext(rawContext) {
|
|
|
7109
7483
|
|
|
7110
7484
|
|
|
7111
7485
|
// ─── Socket.io Auth ───────────────────────────────────────────────────────────
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
}
|
|
7116
|
-
io.use((socket, next) => {
|
|
7117
|
-
if (tokensMatch(socketAuthToken(socket), config.authToken)) return next();
|
|
7118
|
-
next(new Error('Unauthorized'));
|
|
7486
|
+
// A sub-agent's record goes live only to the pages whose viewer shows it.
|
|
7487
|
+
const subagentViewerRooms = createSubagentViewerRooms({
|
|
7488
|
+
getRun: (runId) => stmts.getSubagentRun?.get?.(runId) || null,
|
|
7119
7489
|
});
|
|
7490
|
+
// The handshake presents the token as `auth.token` or the cookie; a query
|
|
7491
|
+
// `token` is not read (see services/relay-auth.mjs).
|
|
7492
|
+
io.use(relayAuth.socketMiddleware);
|
|
7120
7493
|
|
|
7121
7494
|
io.engine.on('initial_headers', (headers, req) => {
|
|
7122
7495
|
const sessionId = ensureSessionId(req);
|
|
@@ -7128,7 +7501,7 @@ io.on('connection', (socket) => {
|
|
|
7128
7501
|
// socket bypasses the io.use() token gate above. Re-validate here and drop it
|
|
7129
7502
|
// on mismatch so a recovered session still requires the auth token (and a
|
|
7130
7503
|
// rotated token takes effect immediately).
|
|
7131
|
-
if (socket.recovered && !
|
|
7504
|
+
if (socket.recovered && !relayAuth.socketTokenValid(socket)) {
|
|
7132
7505
|
socket.disconnect(true);
|
|
7133
7506
|
return;
|
|
7134
7507
|
}
|
|
@@ -7150,6 +7523,7 @@ io.on('connection', (socket) => {
|
|
|
7150
7523
|
// deviceVisible flag restored by connectionStateRecovery.
|
|
7151
7524
|
activeDeviceTracker.registerSocket(socket);
|
|
7152
7525
|
tmuxInspectorSocketService.registerSocket(socket);
|
|
7526
|
+
subagentViewerRooms.registerSocket(socket);
|
|
7153
7527
|
});
|
|
7154
7528
|
|
|
7155
7529
|
// ─── SSH Reverse Tunnel ────────────────────────────────────────────────────────
|
|
@@ -7198,11 +7572,42 @@ const previewsConfig = normalizePreviewsConfig(config.previews || {}, {
|
|
|
7198
7572
|
for (const previewConfigError of previewsConfig.errors) {
|
|
7199
7573
|
console.warn(`${runtimeLogPrefix()}PREVIEW LANE disabled: ${previewConfigError}`);
|
|
7200
7574
|
}
|
|
7575
|
+
// Static previews are jailed to the conversation's workspace root; a preview
|
|
7576
|
+
// registered without a conversation falls back to the server-wide root.
|
|
7577
|
+
const resolvePreviewWorkspaceRoot = (conversationId) => {
|
|
7578
|
+
if (String(conversationId || '').trim()) {
|
|
7579
|
+
const state = resolveConversationWorkspaceState({ conversationId });
|
|
7580
|
+
const rootPath = String(state?.currentWorkspaceRootPath || '').trim();
|
|
7581
|
+
if (rootPath) return rootPath;
|
|
7582
|
+
}
|
|
7583
|
+
return currentWorkspaceRootPath();
|
|
7584
|
+
};
|
|
7585
|
+
// Persisted in the previews table and restored here, each row checked again
|
|
7586
|
+
// with the registration rules (see createPreviewRegistry).
|
|
7201
7587
|
const previewRegistry = createPreviewRegistry({
|
|
7202
7588
|
config: previewsConfig,
|
|
7203
7589
|
onChange: ({ previews }) => {
|
|
7204
7590
|
try { io.emit('previews', { previews }); } catch {}
|
|
7205
7591
|
},
|
|
7592
|
+
store: createSqlitePreviewStore(db, { logger: console }),
|
|
7593
|
+
hostBootId: readHostBootId(),
|
|
7594
|
+
isSameHostBoot,
|
|
7595
|
+
checkConversation: (conversationId) => {
|
|
7596
|
+
const row = stmts.getConvAnyStatus.get(conversationId);
|
|
7597
|
+
return !!row && row.status !== 'deleted';
|
|
7598
|
+
},
|
|
7599
|
+
// At boot no worker has reported, so the configured root comes first while
|
|
7600
|
+
// the preview may have been registered under the worker's runtime root:
|
|
7601
|
+
// either of the conversation's roots keeps it.
|
|
7602
|
+
checkStaticRoot: (rootDir, conversationId) => {
|
|
7603
|
+
const state = String(conversationId || '').trim()
|
|
7604
|
+
? resolveConversationWorkspaceState({ conversationId }) : null;
|
|
7605
|
+
return validateStaticRootInAny(rootDir, [
|
|
7606
|
+
resolvePreviewWorkspaceRoot(conversationId),
|
|
7607
|
+
state?.runtimeWorkspaceRootPath,
|
|
7608
|
+
state?.configuredWorkspaceRootPath,
|
|
7609
|
+
]);
|
|
7610
|
+
},
|
|
7206
7611
|
});
|
|
7207
7612
|
const previewProxyServer = createPreviewProxyServer({
|
|
7208
7613
|
registry: previewRegistry,
|
|
@@ -7226,16 +7631,7 @@ registerPreviewRoutes(app, {
|
|
|
7226
7631
|
auth,
|
|
7227
7632
|
previewRegistry,
|
|
7228
7633
|
previewHealthProbe,
|
|
7229
|
-
|
|
7230
|
-
// registered without a conversation falls back to the server-wide root.
|
|
7231
|
-
resolvePreviewWorkspaceRoot: (conversationId) => {
|
|
7232
|
-
if (String(conversationId || '').trim()) {
|
|
7233
|
-
const state = resolveConversationWorkspaceState({ conversationId });
|
|
7234
|
-
const rootPath = String(state?.currentWorkspaceRootPath || '').trim();
|
|
7235
|
-
if (rootPath) return rootPath;
|
|
7236
|
-
}
|
|
7237
|
-
return currentWorkspaceRootPath();
|
|
7238
|
-
},
|
|
7634
|
+
resolvePreviewWorkspaceRoot,
|
|
7239
7635
|
// A preview published while a turn is processing gets pinned to that turn's
|
|
7240
7636
|
// transcript (snapshot, keyed on the queue id and linked to the response at
|
|
7241
7637
|
// finalize — the relay_activity two-step). No in-flight turn → no card.
|
|
@@ -7259,6 +7655,7 @@ function clearRuntimeTimers() {
|
|
|
7259
7655
|
if (!timer) continue;
|
|
7260
7656
|
try { clearInterval(timer); } catch {}
|
|
7261
7657
|
}
|
|
7658
|
+
subagentRecordRetention?.stop();
|
|
7262
7659
|
runtimeTimers.cliStatus = null;
|
|
7263
7660
|
runtimeTimers.ownerWatchdog = null;
|
|
7264
7661
|
runtimeTimers.staleRecovery = null;
|
|
@@ -7306,15 +7703,17 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
7306
7703
|
console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
|
|
7307
7704
|
}
|
|
7308
7705
|
sessionWorkerWebSocketService.stop();
|
|
7706
|
+
try { relayEventService.stop(); } catch {}
|
|
7707
|
+
try { relayEventLinkService.stop(); } catch {}
|
|
7309
7708
|
tmuxInspectorSocketService.stop();
|
|
7310
7709
|
stopWorkspaceFileWatcher();
|
|
7311
7710
|
sshTunnelManager.stop();
|
|
7312
7711
|
cloudflaredTunnelManager.stop();
|
|
7313
7712
|
previewHealthProbe.stop();
|
|
7314
7713
|
try { remoteRelayRegistry.stopHealthLoop(); } catch {}
|
|
7315
|
-
// Previews
|
|
7316
|
-
//
|
|
7317
|
-
previewRegistry.
|
|
7714
|
+
// Previews persist across the restart (the next runtime restores them); only
|
|
7715
|
+
// the throttled last-use times still need writing.
|
|
7716
|
+
previewRegistry.flushHits();
|
|
7318
7717
|
void previewProxyServer.stop().catch(() => {});
|
|
7319
7718
|
try { relaySingletonGuard.release(); } catch (error) {
|
|
7320
7719
|
console.warn(`${runtimeLogPrefix()}Failed to release singleton lock: ${error?.message || error}`);
|