@oar-sh/oar 0.9.2 → 0.9.4
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/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
import { liveResendsByOriginal } from '../services/steer-resend.mjs';
|
|
2
3
|
import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
|
|
3
4
|
|
|
4
5
|
import fs from 'fs';
|
|
5
6
|
import path from 'path';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
import { randomUUID } from 'crypto';
|
|
8
|
-
import { spawn } from 'child_process';
|
|
9
9
|
import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
|
|
10
10
|
import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
|
|
11
11
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
import { workspaceRootDisplayName } from '../workspace-root.mjs';
|
|
36
36
|
import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
|
|
37
37
|
import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
|
|
38
|
+
import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
|
|
39
|
+
import { admitRemoteRelayRequest, publicRemoteRelayOrigin } from '../services/remote-relay-inbound.mjs';
|
|
38
40
|
import {
|
|
39
41
|
RELAUNCH_COALESCE_WINDOW_MS,
|
|
40
42
|
buildRelaunchRequestKey,
|
|
@@ -685,26 +687,6 @@ function isOpenAIImageModelId(model = '') {
|
|
|
685
687
|
return normalized.startsWith('gpt-image-') || normalized.startsWith('dall-e-');
|
|
686
688
|
}
|
|
687
689
|
|
|
688
|
-
function runHostSuspendToRam() {
|
|
689
|
-
if (process.platform !== 'win32') {
|
|
690
|
-
return { ok: false, statusCode: 501, error: 'Host suspend is only supported on Windows' };
|
|
691
|
-
}
|
|
692
|
-
try {
|
|
693
|
-
const child = spawn('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
|
|
694
|
-
detached: true,
|
|
695
|
-
stdio: 'ignore',
|
|
696
|
-
windowsHide: true,
|
|
697
|
-
});
|
|
698
|
-
child.unref?.();
|
|
699
|
-
return {
|
|
700
|
-
ok: true,
|
|
701
|
-
command: 'rundll32.exe powrprof.dll,SetSuspendState 0,0,0',
|
|
702
|
-
};
|
|
703
|
-
} catch (error) {
|
|
704
|
-
return { ok: false, statusCode: 500, error: error?.message || 'Failed to launch host suspend command' };
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
690
|
function isPidAlive(pidValue) {
|
|
709
691
|
const pid = Number(pidValue);
|
|
710
692
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -947,13 +929,19 @@ export function normalizePreferredReasoningEffort(value) {
|
|
|
947
929
|
return String(value || '').trim().toLowerCase();
|
|
948
930
|
}
|
|
949
931
|
|
|
950
|
-
|
|
932
|
+
// The web client caps what it saves at the same length (MAX_DRAFT_TEXT_LENGTH
|
|
933
|
+
// in public/app/conversation-draft-sync.mjs); a test pins them together.
|
|
934
|
+
export const MAX_CONVERSATION_DRAFT_LENGTH = 20_000;
|
|
951
935
|
|
|
952
936
|
function normalizeConversationDraftText(value, { maxLength = MAX_CONVERSATION_DRAFT_LENGTH } = {}) {
|
|
953
937
|
const text = String(value ?? '');
|
|
954
938
|
if (!text.trim()) return '';
|
|
955
939
|
if (text.length <= maxLength) return text;
|
|
956
|
-
|
|
940
|
+
// Never split a surrogate pair: SQLite would store the lone half as U+FFFD,
|
|
941
|
+
// and the client (which cuts the same way) would no longer match its draft.
|
|
942
|
+
const cut = text.slice(0, maxLength);
|
|
943
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
944
|
+
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
957
945
|
}
|
|
958
946
|
|
|
959
947
|
export function normalizeOptionalIsoTimestamp(value) {
|
|
@@ -1743,17 +1731,24 @@ export function buildConversationMessages({
|
|
|
1743
1731
|
.map((row) => [String(row?.id || '').trim(), row])
|
|
1744
1732
|
.filter(([id]) => !!id),
|
|
1745
1733
|
);
|
|
1734
|
+
// A stopped steer's marker says "Resent" from the data, so it holds across
|
|
1735
|
+
// reloads and devices (the relay refuses a second Resend on the same basis).
|
|
1736
|
+
const liveResends = liveResendsByOriginal(dbMessages, queueRows);
|
|
1746
1737
|
const normalizedDbMessages = Array.isArray(dbMessages)
|
|
1747
1738
|
? dbMessages.map((message) => {
|
|
1748
1739
|
const id = String(message?.id || '').trim();
|
|
1740
|
+
// The queue's link is pruned with old rows; the message's own column
|
|
1741
|
+
// keeps it, so a reload still anchors the reply under its prompt.
|
|
1749
1742
|
const sourceMessageId = message?.role === 'assistant'
|
|
1750
|
-
? (responseMessageToSourceId.get(id) || undefined)
|
|
1743
|
+
? (responseMessageToSourceId.get(id) || String(message?.source_message_id || '').trim() || undefined)
|
|
1751
1744
|
: undefined;
|
|
1752
1745
|
// A user row's queue entry shares its id; assistant rows reach the same
|
|
1753
1746
|
// entry through the response mapping. Both need it for the effort tag.
|
|
1754
1747
|
const queueRow = sourceMessageId
|
|
1755
1748
|
? queueRowsById.get(sourceMessageId)
|
|
1756
1749
|
: (message?.role === 'user' ? queueRowsById.get(id) : null);
|
|
1750
|
+
// Provenance of a prompt another relay's agent sent: the bubble's badge.
|
|
1751
|
+
const origin = message?.role === 'user' ? parseRemoteRelayOriginJson(message?.origin_json) : null;
|
|
1757
1752
|
return {
|
|
1758
1753
|
activities: message?.role === 'assistant' ? (relayActivitiesByMessageId.get(id) || []) : [],
|
|
1759
1754
|
thoughts: message?.role === 'assistant' ? (relayThoughtsByMessageId.get(id) || []) : [],
|
|
@@ -1775,6 +1770,16 @@ export function buildConversationMessages({
|
|
|
1775
1770
|
timestamp: message?.timestamp,
|
|
1776
1771
|
sourceMessageId,
|
|
1777
1772
|
executedProvider: message?.executed_provider || message?.executedProvider || undefined,
|
|
1773
|
+
// 'continuation' badges a self-started turn; 'absorbed' marks a
|
|
1774
|
+
// reply that continues through the next (steered) user message;
|
|
1775
|
+
// 'folded'/'stopped' are a steer's settle markers. Without this the
|
|
1776
|
+
// live-appended badge/merge/marker vanished on reload.
|
|
1777
|
+
kind: message?.kind || undefined,
|
|
1778
|
+
resendOfMessageId: message?.role === 'user' ? (message?.resend_of_message_id || undefined) : undefined,
|
|
1779
|
+
resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
|
|
1780
|
+
? (liveResends.get(sourceMessageId) || undefined)
|
|
1781
|
+
: undefined,
|
|
1782
|
+
...(origin ? { origin } : {}),
|
|
1778
1783
|
};
|
|
1779
1784
|
})
|
|
1780
1785
|
: [];
|
|
@@ -2084,6 +2089,13 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2084
2089
|
windowsBootAutostartService,
|
|
2085
2090
|
isSha256,
|
|
2086
2091
|
uploadPathForSha,
|
|
2092
|
+
hostSuspendService = null,
|
|
2093
|
+
collectHostActivity = null,
|
|
2094
|
+
hostSuspendPlatform = process.platform,
|
|
2095
|
+
// Remote relays (server/services/remote-relay-inbound.mjs): origin on
|
|
2096
|
+
// conversations another relay's agent created, and the inbound switch.
|
|
2097
|
+
// Absent in older wiring and most tests, where both are no-ops.
|
|
2098
|
+
remoteRelayInbound = null,
|
|
2087
2099
|
} = deps;
|
|
2088
2100
|
const sdkSessionSyncService = createSdkSessionSyncService(db);
|
|
2089
2101
|
// Generic synchronous transaction runner for the session-sync route: binding
|
|
@@ -2112,9 +2124,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2112
2124
|
}
|
|
2113
2125
|
}
|
|
2114
2126
|
|
|
2115
|
-
const SDK_DELETE_WAIT_TIMEOUT_MS = 12_000;
|
|
2116
|
-
const SDK_DELETE_POLL_MS = 200;
|
|
2117
2127
|
const SDK_DELETE_STALE_PROCESSING_MS = 60_000;
|
|
2128
|
+
const CLI_SESSION_DELETE_TIMEOUT_MS = 10_000;
|
|
2129
|
+
const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
2130
|
+
// A session id that is safe to join into a path under the session-state root.
|
|
2131
|
+
const SESSION_FOLDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
2118
2132
|
const markConversationDeleted = db.prepare(`UPDATE conversations SET status = 'deleted', updated_at = ? WHERE id = ?`);
|
|
2119
2133
|
const getActiveQueueForMessage = db.prepare(`
|
|
2120
2134
|
SELECT id
|
|
@@ -2143,6 +2157,20 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2143
2157
|
WHERE conversation_id = ?
|
|
2144
2158
|
AND status IN ('pending', 'processing', 'parked')
|
|
2145
2159
|
`);
|
|
2160
|
+
const countProcessingConversationQueueRows = db.prepare(`
|
|
2161
|
+
SELECT COUNT(*) AS count
|
|
2162
|
+
FROM queue
|
|
2163
|
+
WHERE conversation_id = ?
|
|
2164
|
+
AND status = 'processing'
|
|
2165
|
+
`);
|
|
2166
|
+
const findOtherLiveConversationForSdkSession = db.prepare(`
|
|
2167
|
+
SELECT id
|
|
2168
|
+
FROM conversations
|
|
2169
|
+
WHERE sdk_session_id = ?
|
|
2170
|
+
AND id != ?
|
|
2171
|
+
AND status != 'deleted'
|
|
2172
|
+
LIMIT 1
|
|
2173
|
+
`);
|
|
2146
2174
|
const getConversationUsageSnapshotAggregate = db.prepare(`
|
|
2147
2175
|
SELECT
|
|
2148
2176
|
COUNT(*) AS snapshot_count,
|
|
@@ -2206,10 +2234,6 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2206
2234
|
const sessionLaunchMutex = createKeyedMutex();
|
|
2207
2235
|
const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
|
|
2208
2236
|
|
|
2209
|
-
function sleep(ms) {
|
|
2210
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
2237
|
// Layer every enabled provider's models onto the base Copilot catalog.
|
|
2214
2238
|
function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
|
|
2215
2239
|
return buildModelCatalogWithGrokProvider(
|
|
@@ -2292,16 +2316,121 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2292
2316
|
return true;
|
|
2293
2317
|
}
|
|
2294
2318
|
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2319
|
+
/**
|
|
2320
|
+
* Whether a conversation is still working, which blocks deleting it: a turn
|
|
2321
|
+
* is running (including one waiting on a question card or an approval) or
|
|
2322
|
+
* background tasks are live. Both only count while the session's worker is
|
|
2323
|
+
* alive: a dead worker runs nothing, and the processing rows or task set it
|
|
2324
|
+
* left behind would otherwise make the conversation undeletable. A session
|
|
2325
|
+
* the registry has never seen may still be running a turn, so its
|
|
2326
|
+
* processing rows count.
|
|
2327
|
+
*
|
|
2328
|
+
* A worker's pid decides whether it is alive; its status only when it has
|
|
2329
|
+
* none. A turn that fails marks a worker that is still running 'error',
|
|
2330
|
+
* and it stays so until its next turn, background tasks and all.
|
|
2331
|
+
*/
|
|
2332
|
+
function describeConversationActivity(conversationId, workerSessionId) {
|
|
2333
|
+
const worker = sessionWorkerRegistry?.getWorker?.(workerSessionId) || null;
|
|
2334
|
+
const workerAlive = !!worker && (worker.pid
|
|
2335
|
+
? isPidAlive(worker.pid)
|
|
2336
|
+
: LIVE_WORKER_STATUSES.has(String(worker.status || '').trim().toLowerCase()));
|
|
2337
|
+
const processingCount = Number(countProcessingConversationQueueRows.get(conversationId)?.count || 0);
|
|
2338
|
+
const taskKeys = [...new Set([conversationId, workerSessionId])];
|
|
2339
|
+
const liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
|
|
2340
|
+
return {
|
|
2341
|
+
turnRunning: processingCount > 0 && (workerAlive || !worker),
|
|
2342
|
+
backgroundTaskCount: liveTasks.length,
|
|
2343
|
+
// Tasks the panel shows no Stop button for (Copilot detached shells).
|
|
2344
|
+
unstoppableTaskCount: liveTasks.filter((task) => task?.stoppable === false).length,
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function withDeadline(promise, timeoutMs, label) {
|
|
2349
|
+
let timer = null;
|
|
2350
|
+
return Promise.race([
|
|
2351
|
+
promise,
|
|
2352
|
+
new Promise((_, reject) => {
|
|
2353
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
|
|
2354
|
+
timer.unref?.();
|
|
2355
|
+
}),
|
|
2356
|
+
]).finally(() => clearTimeout(timer));
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/**
|
|
2360
|
+
* The Claude CLI's footprint for one session: its transcript
|
|
2361
|
+
* (`<projectDir>/<nativeSessionId>.jsonl`) and the per-session folder beside
|
|
2362
|
+
* it. Nothing else in the project folder is touched — it holds every other
|
|
2363
|
+
* session of that workspace and the project memory.
|
|
2364
|
+
*/
|
|
2365
|
+
function deleteClaudeCliSession({ conversation, runtimeSession }) {
|
|
2366
|
+
const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
|
|
2367
|
+
if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return { deleted: null };
|
|
2368
|
+
const anchors = resolveClaudeSessionRoot({
|
|
2369
|
+
claudeNativeSessionId: nativeSessionId,
|
|
2370
|
+
workspaceRootPath: String(
|
|
2371
|
+
conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '',
|
|
2372
|
+
).trim(),
|
|
2373
|
+
});
|
|
2374
|
+
if (!anchors?.projectDirPath) return { deleted: null };
|
|
2375
|
+
const projectDir = path.resolve(anchors.projectDirPath);
|
|
2376
|
+
const targets = [anchors.transcriptPath, anchors.sessionRootExists ? anchors.sessionRootPath : '']
|
|
2377
|
+
.filter(Boolean)
|
|
2378
|
+
.map((target) => path.resolve(target))
|
|
2379
|
+
.filter((target) => path.dirname(target) === projectDir
|
|
2380
|
+
&& [nativeSessionId, `${nativeSessionId}.jsonl`].includes(path.basename(target)));
|
|
2381
|
+
for (const target of targets) fs.rmSync(target, { recursive: true, force: true });
|
|
2382
|
+
return { deleted: targets.length > 0 };
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
/**
|
|
2386
|
+
* The Copilot session, through the runtime's own deleteSession(). When that
|
|
2387
|
+
* fails the request goes on the SDK delete queue, which a CLI-side consumer
|
|
2388
|
+
* (the extension engine) still works off.
|
|
2389
|
+
*/
|
|
2390
|
+
async function deleteCopilotCliSession({ sdkSessionId, conversationId }) {
|
|
2391
|
+
const stateRoot = String(resolveSessionStateRoot?.() || '').trim();
|
|
2392
|
+
const sessionDir = stateRoot && SESSION_FOLDER_ID_PATTERN.test(sdkSessionId) ? path.join(stateRoot, sdkSessionId) : '';
|
|
2393
|
+
// A conversation deleted before its first turn has no session on disk yet
|
|
2394
|
+
// (the runtime would answer "Session file not found").
|
|
2395
|
+
if (sessionDir && !fs.existsSync(sessionDir)) return { deleted: null };
|
|
2396
|
+
if (typeof sdkSessionImportService?.deleteSession !== 'function') return { deleted: null };
|
|
2397
|
+
try {
|
|
2398
|
+
await withDeadline(
|
|
2399
|
+
sdkSessionImportService.deleteSession(sdkSessionId),
|
|
2400
|
+
CLI_SESSION_DELETE_TIMEOUT_MS,
|
|
2401
|
+
'Copilot deleteSession()',
|
|
2402
|
+
);
|
|
2403
|
+
return { deleted: true };
|
|
2404
|
+
} catch (error) {
|
|
2405
|
+
const reason = String(error?.message || error);
|
|
2406
|
+
if (/Session file not found/i.test(reason)) {
|
|
2407
|
+
// The runtime keeps no session for it, only this session's own
|
|
2408
|
+
// folder (a run that never wrote its session file): nothing to retry.
|
|
2409
|
+
if (sessionDir) fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
2410
|
+
return { deleted: sessionDir ? true : null };
|
|
2411
|
+
}
|
|
2412
|
+
console.warn(`[delete] Copilot session ${shortId(sdkSessionId)} was not deleted: ${reason}`);
|
|
2413
|
+
enqueueSdkDeleteRequest(sdkSessionId, conversationId);
|
|
2414
|
+
return { deleted: false, queued: true };
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
/** The conversation's CLI-side session, per provider; null where the relay keeps none. */
|
|
2419
|
+
async function deleteCliSession({ conversation, conversationId, sdkSessionId, runtimeSession }) {
|
|
2420
|
+
const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
|
|
2421
|
+
try {
|
|
2422
|
+
if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
|
|
2423
|
+
if (!sdkSessionId) return { deleted: null };
|
|
2424
|
+
if (!provider || provider === 'github' || provider === 'openai') {
|
|
2425
|
+
return await deleteCopilotCliSession({ sdkSessionId, conversationId });
|
|
2426
|
+
}
|
|
2427
|
+
return { deleted: null };
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
// The conversation is already tombstoned: a file the OS will not let go
|
|
2430
|
+
// of must not leave it half deleted.
|
|
2431
|
+
console.warn(`[delete] CLI session of ${shortId(conversationId)} was not deleted: ${error?.message || error}`);
|
|
2432
|
+
return { deleted: false };
|
|
2433
|
+
}
|
|
2305
2434
|
}
|
|
2306
2435
|
|
|
2307
2436
|
function finalizeDeletedConversationsForSdkSession(sdkSessionId) {
|
|
@@ -2436,7 +2565,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2436
2565
|
(stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId),
|
|
2437
2566
|
);
|
|
2438
2567
|
const queueRows = db.prepare(`
|
|
2439
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
2568
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
2440
2569
|
FROM queue
|
|
2441
2570
|
WHERE conversation_id = ?
|
|
2442
2571
|
`).all(resolvedConversationId);
|
|
@@ -2495,7 +2624,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2495
2624
|
});
|
|
2496
2625
|
messages = messages.map((message) => {
|
|
2497
2626
|
const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
|
|
2498
|
-
const
|
|
2627
|
+
const withSource = sourceMessageId ? { ...message, sourceMessageId } : message;
|
|
2628
|
+
// A public share never shows another relay's address or ids.
|
|
2629
|
+
const nextMessage = withSource.origin
|
|
2630
|
+
? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
|
|
2631
|
+
: withSource;
|
|
2499
2632
|
const attachments = Array.isArray(nextMessage?.attachments)
|
|
2500
2633
|
? nextMessage.attachments.map((attachment) => rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken))
|
|
2501
2634
|
: nextMessage?.attachments;
|
|
@@ -2644,6 +2777,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2644
2777
|
draftAttachments: parseDraftAttachmentsColumn(r.draft_attachments),
|
|
2645
2778
|
draftUpdatedAt: r.draft_updated_at || null,
|
|
2646
2779
|
draftUpdatedByClientId: r.draft_updated_by_client_id || null,
|
|
2780
|
+
// Set on conversations another relay's agent created ("via <relay>").
|
|
2781
|
+
origin: parseRemoteRelayOriginJson(r.origin_json),
|
|
2647
2782
|
};
|
|
2648
2783
|
});
|
|
2649
2784
|
|
|
@@ -3181,7 +3316,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3181
3316
|
});
|
|
3182
3317
|
const dbMessages = stmts.getMessages.all(conversationId);
|
|
3183
3318
|
const queueRows = db.prepare(`
|
|
3184
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
3319
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
3185
3320
|
FROM queue
|
|
3186
3321
|
WHERE conversation_id = ?
|
|
3187
3322
|
`).all(conversationId);
|
|
@@ -3285,6 +3420,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3285
3420
|
draftAttachments: parseDraftAttachmentsColumn(conv.draft_attachments),
|
|
3286
3421
|
draftUpdatedAt: conv.draft_updated_at || null,
|
|
3287
3422
|
draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
|
|
3423
|
+
origin: parseRemoteRelayOriginJson(conv.origin_json),
|
|
3288
3424
|
messages: history.messages,
|
|
3289
3425
|
pageInfo: history.pageInfo,
|
|
3290
3426
|
refreshed: true,
|
|
@@ -3583,7 +3719,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3583
3719
|
});
|
|
3584
3720
|
const dbMessages = stmts.getMessages.all(resolvedConversationId);
|
|
3585
3721
|
const queueRows = db.prepare(`
|
|
3586
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
3722
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
3587
3723
|
FROM queue
|
|
3588
3724
|
WHERE conversation_id = ?
|
|
3589
3725
|
`).all(resolvedConversationId);
|
|
@@ -3680,6 +3816,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3680
3816
|
updatedAt: conv.updated_at,
|
|
3681
3817
|
sessionUsageSummary,
|
|
3682
3818
|
inFlight,
|
|
3819
|
+
// A queued (pending/parked) row counts too: inFlight covers only the
|
|
3820
|
+
// processing one. Another relay's agent tells "still queued" from
|
|
3821
|
+
// "cancelled" with it.
|
|
3822
|
+
activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
|
|
3683
3823
|
backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
|
|
3684
3824
|
previews: listConversationPreviews?.(resolvedConversationId) || [],
|
|
3685
3825
|
preferredRelayMode: preferences.preferredRelayMode,
|
|
@@ -3689,6 +3829,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3689
3829
|
draftAttachments: parseDraftAttachmentsColumn(conv.draft_attachments),
|
|
3690
3830
|
draftUpdatedAt: conv.draft_updated_at || null,
|
|
3691
3831
|
draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
|
|
3832
|
+
// Set on conversations another relay's agent created ("via <relay>").
|
|
3833
|
+
origin: parseRemoteRelayOriginJson(conv.origin_json),
|
|
3692
3834
|
messages: history.messages,
|
|
3693
3835
|
pageInfo: history.pageInfo,
|
|
3694
3836
|
});
|
|
@@ -3854,15 +3996,30 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3854
3996
|
if (!existing || String(existing.status || '').trim() === 'deleted') {
|
|
3855
3997
|
return res.status(404).json({ error: 'Conversation not found' });
|
|
3856
3998
|
}
|
|
3857
|
-
|
|
3858
|
-
|
|
3999
|
+
// A null base means "I have seen no draft" and conflicts with any versioned
|
|
4000
|
+
// draft — but only from clients that declare the versioned protocol. Older
|
|
4001
|
+
// tabs also send an explicit null (after every keystroke), and a 409 there
|
|
4002
|
+
// would revert their typing, so for them null stays unconditional.
|
|
4003
|
+
const baseDraftUpdatedAtValue = req.body?.baseDraftUpdatedAt !== undefined
|
|
4004
|
+
? req.body.baseDraftUpdatedAt
|
|
4005
|
+
: req.body?.base_draft_updated_at;
|
|
4006
|
+
const speaksVersionedDraftProtocol = Number(req.body?.draftSyncVersion) >= 2;
|
|
4007
|
+
const comparesDraftVersion = baseDraftUpdatedAtValue !== undefined
|
|
4008
|
+
&& (baseDraftUpdatedAtValue !== null || speaksVersionedDraftProtocol);
|
|
3859
4009
|
const normalizedBaseDraftUpdatedAt = normalizeOptionalIsoTimestamp(baseDraftUpdatedAtValue);
|
|
3860
4010
|
const suppliedBaseTimestamp = String(baseDraftUpdatedAtValue ?? '').trim();
|
|
3861
4011
|
if (comparesDraftVersion && suppliedBaseTimestamp && !normalizedBaseDraftUpdatedAt) {
|
|
3862
4012
|
return res.status(400).json({ error: 'Invalid baseDraftUpdatedAt timestamp' });
|
|
3863
4013
|
}
|
|
3864
4014
|
const existingDraftUpdatedAt = normalizeOptionalIsoTimestamp(existing.draft_updated_at);
|
|
3865
|
-
|
|
4015
|
+
// A pagehide copy taken while this client's own send was in flight carries
|
|
4016
|
+
// the pre-send base; the send transaction has since moved the version. That
|
|
4017
|
+
// move (recorded with the sender's client id) is the only mismatch it may
|
|
4018
|
+
// pass — a newer draft from anyone else still conflicts.
|
|
4019
|
+
const acceptsOwnSendVersion = req.body?.acceptOwnSend === true
|
|
4020
|
+
&& !!senderClientId
|
|
4021
|
+
&& String(existing.draft_updated_by_client_id || '') === senderClientId;
|
|
4022
|
+
if (!acceptsOwnSendVersion && hasConversationDraftVersionConflict({
|
|
3866
4023
|
existingDraftUpdatedAt,
|
|
3867
4024
|
baseDraftUpdatedAt: normalizedBaseDraftUpdatedAt,
|
|
3868
4025
|
compareEnabled: comparesDraftVersion,
|
|
@@ -3874,6 +4031,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3874
4031
|
conflict: true,
|
|
3875
4032
|
conversationId,
|
|
3876
4033
|
draftText: String(existing.draft_text || ''),
|
|
4034
|
+
draftAttachments: parseDraftAttachmentsColumn(existing.draft_attachments),
|
|
3877
4035
|
draftUpdatedAt: existingDraftUpdatedAt,
|
|
3878
4036
|
draftUpdatedByClientId: existing.draft_updated_by_client_id || null,
|
|
3879
4037
|
});
|
|
@@ -4275,44 +4433,135 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4275
4433
|
}
|
|
4276
4434
|
|
|
4277
4435
|
try {
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
resolveSessionStateRoot,
|
|
4288
|
-
});
|
|
4289
|
-
const orphanedUploads = collectOrphanedUploadsFromConversation(id);
|
|
4290
|
-
hardDeleteConversationRows(id);
|
|
4291
|
-
deleteOrphanedUploads(orphanedUploads);
|
|
4292
|
-
io.emit('conversation_deleted', { conversationId: id });
|
|
4293
|
-
return res.json({ ok: true });
|
|
4294
|
-
}
|
|
4295
|
-
|
|
4296
|
-
markConversationDeleted.run(new Date().toISOString(), id);
|
|
4297
|
-
stmts.markDeletedSdkSession.run(sdkSessionId, new Date().toISOString());
|
|
4298
|
-
if (sdkSessionId !== id) {
|
|
4299
|
-
// Also tombstone the conversation id itself for legacy/synthetic sdk id fallback paths.
|
|
4300
|
-
stmts.markDeletedSdkSession.run(id, new Date().toISOString());
|
|
4301
|
-
}
|
|
4302
|
-
enqueueSdkDeleteRequest(sdkSessionId, id);
|
|
4303
|
-
const awaited = await waitForSdkDeleteCompletion(sdkSessionId);
|
|
4304
|
-
if (awaited.completed) return res.json({ ok: true });
|
|
4305
|
-
|
|
4306
|
-
io.emit('conversation_delete_pending', { conversationId: id });
|
|
4307
|
-
return res.json({ ok: true, pending: true });
|
|
4436
|
+
// Session workers are keyed by the SDK session id, which is the
|
|
4437
|
+
// conversation id until a session is bound. Serialized with launches and
|
|
4438
|
+
// relaunches of that session, and with a second delete of it.
|
|
4439
|
+
const workerSessionId = String(existing.sdk_session_id || '').trim() || id;
|
|
4440
|
+
const outcome = await sessionLaunchMutex.runExclusive(
|
|
4441
|
+
workerSessionId,
|
|
4442
|
+
() => deleteConversationNow(id),
|
|
4443
|
+
);
|
|
4444
|
+
return res.status(outcome.statusCode).json(outcome.body);
|
|
4308
4445
|
} catch (error) {
|
|
4309
4446
|
console.warn(`[archive] Delete failed for ${id}: ${error?.message || error}`);
|
|
4310
4447
|
return res.status(500).json({ error: 'Failed to delete conversation' });
|
|
4311
4448
|
}
|
|
4312
4449
|
});
|
|
4313
4450
|
|
|
4451
|
+
/** DELETE /api/conversation/:id under its session's lock; see the route. */
|
|
4452
|
+
async function deleteConversationNow(id) {
|
|
4453
|
+
const existing = stmts.getConvAnyStatus.get(id);
|
|
4454
|
+
if (!existing) return { statusCode: 200, body: { ok: true, alreadyDeleted: true } };
|
|
4455
|
+
const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
|
|
4456
|
+
const workerSessionId = sdkSessionId || id;
|
|
4457
|
+
|
|
4458
|
+
const activity = describeConversationActivity(id, workerSessionId);
|
|
4459
|
+
if (activity.turnRunning) {
|
|
4460
|
+
return {
|
|
4461
|
+
statusCode: 409,
|
|
4462
|
+
body: {
|
|
4463
|
+
ok: false,
|
|
4464
|
+
error: 'conversation-active',
|
|
4465
|
+
reason: 'turn-running',
|
|
4466
|
+
message: 'This conversation is still working on a reply. Stop it, then delete it.',
|
|
4467
|
+
},
|
|
4468
|
+
};
|
|
4469
|
+
}
|
|
4470
|
+
if (activity.backgroundTaskCount > 0) {
|
|
4471
|
+
return {
|
|
4472
|
+
statusCode: 409,
|
|
4473
|
+
body: {
|
|
4474
|
+
ok: false,
|
|
4475
|
+
error: 'conversation-active',
|
|
4476
|
+
reason: 'background-tasks',
|
|
4477
|
+
// Kill session stops the session's worker, and the delete counts no
|
|
4478
|
+
// tasks for a conversation whose worker is gone.
|
|
4479
|
+
message: activity.unstoppableTaskCount > 0
|
|
4480
|
+
? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then delete it.'
|
|
4481
|
+
: 'Background tasks are still running in this conversation. Stop them in the task panel, then delete it.',
|
|
4482
|
+
},
|
|
4483
|
+
};
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
// A worker and CLI session that another live conversation still uses
|
|
4487
|
+
// stay; only this conversation's own data goes.
|
|
4488
|
+
const sharedWithConversationId = sdkSessionId
|
|
4489
|
+
? String(findOtherLiveConversationForSdkSession.get(sdkSessionId, id)?.id || '').trim() || null
|
|
4490
|
+
: null;
|
|
4491
|
+
// A Copilot session the relay only imported, and never ran, belongs to
|
|
4492
|
+
// whoever started it in the CLI. Deleting the conversation hides and
|
|
4493
|
+
// tombstones it, and nothing more: the relay started no process for it
|
|
4494
|
+
// (the process scan would find that user's own `copilot --resume`), and
|
|
4495
|
+
// its CLI session stays. Asked before the stop, which removes the registry
|
|
4496
|
+
// entry that shows the relay has run it.
|
|
4497
|
+
const importedOnly = !!sdkSessionId && sdkSessionImportService?.isImportedOnly?.(sdkSessionId) === true;
|
|
4498
|
+
|
|
4499
|
+
// An idle worker is stopped before anything is deleted: the route used to
|
|
4500
|
+
// leave it running (and holding its workspace) until the relay restarted.
|
|
4501
|
+
// Nothing is touched when it will not die.
|
|
4502
|
+
let workerStopped = false;
|
|
4503
|
+
if (!sharedWithConversationId && !importedOnly) {
|
|
4504
|
+
const stopped = await stopIdleWorkspaceRootSession({
|
|
4505
|
+
sdkSessionId: workerSessionId,
|
|
4506
|
+
worker: sessionWorkerRegistry?.getWorker?.(workerSessionId) || null,
|
|
4507
|
+
sessionWorkerSupervisor,
|
|
4508
|
+
sessionWorkerRegistry,
|
|
4509
|
+
sessionWorkerProcessInspector,
|
|
4510
|
+
stopOverrides: sessionWorkerStopOverrides,
|
|
4511
|
+
});
|
|
4512
|
+
if (!stopped.ok) {
|
|
4513
|
+
return {
|
|
4514
|
+
statusCode: 409,
|
|
4515
|
+
body: {
|
|
4516
|
+
ok: false,
|
|
4517
|
+
error: 'worker-stop-failed',
|
|
4518
|
+
message: "Couldn't stop this conversation's session, so nothing was deleted. Try again in a moment.",
|
|
4519
|
+
remainingPids: stopped.remainingPids || [],
|
|
4520
|
+
},
|
|
4521
|
+
};
|
|
4522
|
+
}
|
|
4523
|
+
workerStopped = (stopped.stoppedPids || []).length > 0;
|
|
4524
|
+
// Re-armed after the stop, which can outlast the kill block's grace
|
|
4525
|
+
// window: nothing may respawn a worker for the rows deleted below.
|
|
4526
|
+
sessionWorkerSupervisor?.markKilled?.(workerSessionId);
|
|
4527
|
+
for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
const deletedAt = new Date().toISOString();
|
|
4531
|
+
markConversationDeleted.run(deletedAt, id);
|
|
4532
|
+
// Tombstones keep the SDK session importer from bringing it back.
|
|
4533
|
+
stmts.markDeletedSdkSession.run(sdkSessionId || id, deletedAt);
|
|
4534
|
+
if (sdkSessionId && sdkSessionId !== id) stmts.markDeletedSdkSession.run(id, deletedAt);
|
|
4535
|
+
|
|
4536
|
+
const cliSession = sharedWithConversationId || importedOnly
|
|
4537
|
+
? { deleted: null }
|
|
4538
|
+
: await deleteCliSession({
|
|
4539
|
+
conversation: existing,
|
|
4540
|
+
conversationId: id,
|
|
4541
|
+
sdkSessionId,
|
|
4542
|
+
runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
|
|
4543
|
+
});
|
|
4544
|
+
|
|
4545
|
+
cleanupGeneratedImagesForConversation({
|
|
4546
|
+
conversationId: id,
|
|
4547
|
+
sdkSessionId,
|
|
4548
|
+
messageRows: stmts.getMessages?.all?.(id) || [],
|
|
4549
|
+
parseAttachments,
|
|
4550
|
+
hydrateAttachment,
|
|
4551
|
+
resolveSessionStateRoot,
|
|
4552
|
+
});
|
|
4553
|
+
const orphanedUploads = collectOrphanedUploadsFromConversation(id);
|
|
4554
|
+
hardDeleteConversationRows(id);
|
|
4555
|
+
remoteRelayInbound?.repository?.forgetConversation?.(id);
|
|
4556
|
+
deleteOrphanedUploads(orphanedUploads);
|
|
4557
|
+
if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
|
|
4558
|
+
io.emit('conversation_deleted', { conversationId: id });
|
|
4559
|
+
return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
|
|
4560
|
+
}
|
|
4561
|
+
|
|
4314
4562
|
// POST /api/conversation/:id/archive — archive conversation
|
|
4315
4563
|
app.post('/api/conversation/:id/archive', auth, async (req, res) => {
|
|
4564
|
+
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
4316
4565
|
const id = String(req.params.id || '').trim();
|
|
4317
4566
|
if (!id) return res.status(400).json({ error: 'Missing conversation id' });
|
|
4318
4567
|
try {
|
|
@@ -4330,6 +4579,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4330
4579
|
});
|
|
4331
4580
|
|
|
4332
4581
|
app.post('/api/conversation/bootstrap', auth, async (req, res) => {
|
|
4582
|
+
// Another relay's agent starting a session: refused while the inbound
|
|
4583
|
+
// switch is off, otherwise the conversation keeps its provenance.
|
|
4584
|
+
const remoteRequest = admitRemoteRelayRequest(req, res, remoteRelayInbound);
|
|
4585
|
+
if (!remoteRequest) return;
|
|
4333
4586
|
const clientSessionId = String(ensureSessionId(req, res) || '').trim();
|
|
4334
4587
|
const now = new Date().toISOString();
|
|
4335
4588
|
let conversationId = '';
|
|
@@ -4625,6 +4878,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4625
4878
|
// single canonical tuple.
|
|
4626
4879
|
const commitBootstrapState = db.transaction(() => {
|
|
4627
4880
|
stmts.insertConv.run(conversationId, title, now, now);
|
|
4881
|
+
if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
|
|
4628
4882
|
const boundRuntimeSession = ensureRuntimeSessionBinding(
|
|
4629
4883
|
conversationId,
|
|
4630
4884
|
selectedModel,
|
|
@@ -4744,6 +4998,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4744
4998
|
ok: true,
|
|
4745
4999
|
conversationId,
|
|
4746
5000
|
conversation,
|
|
5001
|
+
origin: parseRemoteRelayOriginJson(conversation?.origin_json),
|
|
4747
5002
|
runtimeSessionId: runtimeSession?.id || null,
|
|
4748
5003
|
ownerSessionId,
|
|
4749
5004
|
worker: ownerWorker,
|
|
@@ -4845,6 +5100,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4845
5100
|
activeBridgeOwner: runtimeState.activeBridgeOwner || null,
|
|
4846
5101
|
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
4847
5102
|
relayShutdown: runtimeState.relayShutdown || null,
|
|
5103
|
+
hostSuspend: runtimeState.hostSuspend || null,
|
|
4848
5104
|
platform: process.platform,
|
|
4849
5105
|
features: featureFlags || {},
|
|
4850
5106
|
sessionWorker: sessionWorkerStatus,
|
|
@@ -5939,21 +6195,59 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5939
6195
|
});
|
|
5940
6196
|
});
|
|
5941
6197
|
|
|
6198
|
+
// Deferred host suspend: the request is queued and fulfilled by the
|
|
6199
|
+
// host-suspend service once nothing runs any more (see
|
|
6200
|
+
// docs/plans/2026-09-27-deferred-host-suspend.md).
|
|
6201
|
+
app.get('/api/host/suspend', auth, (req, res) => {
|
|
6202
|
+
if (!hostSuspendService) {
|
|
6203
|
+
return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
|
|
6204
|
+
}
|
|
6205
|
+
const state = hostSuspendService.refresh();
|
|
6206
|
+
let activity = null;
|
|
6207
|
+
if (!state.pending && typeof collectHostActivity === 'function') {
|
|
6208
|
+
try {
|
|
6209
|
+
activity = collectHostActivity({ request: null });
|
|
6210
|
+
} catch (error) {
|
|
6211
|
+
activity = { blockers: [], error: String(error?.message || error) };
|
|
6212
|
+
}
|
|
6213
|
+
}
|
|
6214
|
+
return res.json({
|
|
6215
|
+
ok: true,
|
|
6216
|
+
supported: hostSuspendPlatform === 'win32',
|
|
6217
|
+
state,
|
|
6218
|
+
blockers: state.pending ? state.blockers : (activity?.blockers || []),
|
|
6219
|
+
activity,
|
|
6220
|
+
});
|
|
6221
|
+
});
|
|
6222
|
+
|
|
5942
6223
|
app.post('/api/host/suspend', auth, (req, res) => {
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
});
|
|
6224
|
+
if (!hostSuspendService) {
|
|
6225
|
+
return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
|
|
6226
|
+
}
|
|
6227
|
+
if (hostSuspendPlatform !== 'win32') {
|
|
6228
|
+
return res.status(501).json({ ok: false, error: 'Host suspend is only supported on Windows' });
|
|
5949
6229
|
}
|
|
6230
|
+
const reason = String(req.body?.reason || 'manual-suspend').trim().slice(0, 140) || 'manual-suspend';
|
|
6231
|
+
const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
|
|
6232
|
+
const result = hostSuspendService.request({ reason, requestedBy });
|
|
5950
6233
|
return res.status(202).json({
|
|
5951
6234
|
ok: true,
|
|
5952
6235
|
queued: true,
|
|
5953
|
-
|
|
6236
|
+
accepted: result.accepted !== false,
|
|
6237
|
+
alreadyPending: !!result.alreadyPending,
|
|
6238
|
+
state: result.state,
|
|
5954
6239
|
});
|
|
5955
6240
|
});
|
|
5956
6241
|
|
|
6242
|
+
app.post('/api/host/suspend/cancel', auth, (req, res) => {
|
|
6243
|
+
if (!hostSuspendService) {
|
|
6244
|
+
return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
|
|
6245
|
+
}
|
|
6246
|
+
const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
|
|
6247
|
+
const result = hostSuspendService.cancel({ requestedBy });
|
|
6248
|
+
return res.json({ ok: true, cancelled: !!result.cancelled, state: result.state });
|
|
6249
|
+
});
|
|
6250
|
+
|
|
5957
6251
|
app.get('/api/restart-orchestrator', auth, (req, res) => {
|
|
5958
6252
|
ensureSessionId(req, res);
|
|
5959
6253
|
return res.json({ orchestrator: relayRestartOrchestrator?.getState?.() || null });
|