@oar-sh/oar 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -1,4 +1,5 @@
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';
@@ -947,13 +948,19 @@ export function normalizePreferredReasoningEffort(value) {
947
948
  return String(value || '').trim().toLowerCase();
948
949
  }
949
950
 
950
- const MAX_CONVERSATION_DRAFT_LENGTH = 20_000;
951
+ // The web client caps what it saves at the same length (MAX_DRAFT_TEXT_LENGTH
952
+ // in public/app/conversation-draft-sync.mjs); a test pins them together.
953
+ export const MAX_CONVERSATION_DRAFT_LENGTH = 20_000;
951
954
 
952
955
  function normalizeConversationDraftText(value, { maxLength = MAX_CONVERSATION_DRAFT_LENGTH } = {}) {
953
956
  const text = String(value ?? '');
954
957
  if (!text.trim()) return '';
955
958
  if (text.length <= maxLength) return text;
956
- return text.slice(0, maxLength);
959
+ // Never split a surrogate pair: SQLite would store the lone half as U+FFFD,
960
+ // and the client (which cuts the same way) would no longer match its draft.
961
+ const cut = text.slice(0, maxLength);
962
+ const last = cut.charCodeAt(cut.length - 1);
963
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
957
964
  }
958
965
 
959
966
  export function normalizeOptionalIsoTimestamp(value) {
@@ -1743,11 +1750,16 @@ export function buildConversationMessages({
1743
1750
  .map((row) => [String(row?.id || '').trim(), row])
1744
1751
  .filter(([id]) => !!id),
1745
1752
  );
1753
+ // A stopped steer's marker says "Resent" from the data, so it holds across
1754
+ // reloads and devices (the relay refuses a second Resend on the same basis).
1755
+ const liveResends = liveResendsByOriginal(dbMessages, queueRows);
1746
1756
  const normalizedDbMessages = Array.isArray(dbMessages)
1747
1757
  ? dbMessages.map((message) => {
1748
1758
  const id = String(message?.id || '').trim();
1759
+ // The queue's link is pruned with old rows; the message's own column
1760
+ // keeps it, so a reload still anchors the reply under its prompt.
1749
1761
  const sourceMessageId = message?.role === 'assistant'
1750
- ? (responseMessageToSourceId.get(id) || undefined)
1762
+ ? (responseMessageToSourceId.get(id) || String(message?.source_message_id || '').trim() || undefined)
1751
1763
  : undefined;
1752
1764
  // A user row's queue entry shares its id; assistant rows reach the same
1753
1765
  // entry through the response mapping. Both need it for the effort tag.
@@ -1775,6 +1787,15 @@ export function buildConversationMessages({
1775
1787
  timestamp: message?.timestamp,
1776
1788
  sourceMessageId,
1777
1789
  executedProvider: message?.executed_provider || message?.executedProvider || undefined,
1790
+ // 'continuation' badges a self-started turn; 'absorbed' marks a
1791
+ // reply that continues through the next (steered) user message;
1792
+ // 'folded'/'stopped' are a steer's settle markers. Without this the
1793
+ // live-appended badge/merge/marker vanished on reload.
1794
+ kind: message?.kind || undefined,
1795
+ resendOfMessageId: message?.role === 'user' ? (message?.resend_of_message_id || undefined) : undefined,
1796
+ resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
1797
+ ? (liveResends.get(sourceMessageId) || undefined)
1798
+ : undefined,
1778
1799
  };
1779
1800
  })
1780
1801
  : [];
@@ -2112,9 +2133,11 @@ export function registerSessionsRoutes(app, deps) {
2112
2133
  }
2113
2134
  }
2114
2135
 
2115
- const SDK_DELETE_WAIT_TIMEOUT_MS = 12_000;
2116
- const SDK_DELETE_POLL_MS = 200;
2117
2136
  const SDK_DELETE_STALE_PROCESSING_MS = 60_000;
2137
+ const CLI_SESSION_DELETE_TIMEOUT_MS = 10_000;
2138
+ const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
2139
+ // A session id that is safe to join into a path under the session-state root.
2140
+ const SESSION_FOLDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
2118
2141
  const markConversationDeleted = db.prepare(`UPDATE conversations SET status = 'deleted', updated_at = ? WHERE id = ?`);
2119
2142
  const getActiveQueueForMessage = db.prepare(`
2120
2143
  SELECT id
@@ -2143,6 +2166,20 @@ export function registerSessionsRoutes(app, deps) {
2143
2166
  WHERE conversation_id = ?
2144
2167
  AND status IN ('pending', 'processing', 'parked')
2145
2168
  `);
2169
+ const countProcessingConversationQueueRows = db.prepare(`
2170
+ SELECT COUNT(*) AS count
2171
+ FROM queue
2172
+ WHERE conversation_id = ?
2173
+ AND status = 'processing'
2174
+ `);
2175
+ const findOtherLiveConversationForSdkSession = db.prepare(`
2176
+ SELECT id
2177
+ FROM conversations
2178
+ WHERE sdk_session_id = ?
2179
+ AND id != ?
2180
+ AND status != 'deleted'
2181
+ LIMIT 1
2182
+ `);
2146
2183
  const getConversationUsageSnapshotAggregate = db.prepare(`
2147
2184
  SELECT
2148
2185
  COUNT(*) AS snapshot_count,
@@ -2206,10 +2243,6 @@ export function registerSessionsRoutes(app, deps) {
2206
2243
  const sessionLaunchMutex = createKeyedMutex();
2207
2244
  const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
2208
2245
 
2209
- function sleep(ms) {
2210
- return new Promise((resolve) => setTimeout(resolve, ms));
2211
- }
2212
-
2213
2246
  // Layer every enabled provider's models onto the base Copilot catalog.
2214
2247
  function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
2215
2248
  return buildModelCatalogWithGrokProvider(
@@ -2292,16 +2325,121 @@ export function registerSessionsRoutes(app, deps) {
2292
2325
  return true;
2293
2326
  }
2294
2327
 
2295
- async function waitForSdkDeleteCompletion(sdkSessionId, timeoutMs = SDK_DELETE_WAIT_TIMEOUT_MS) {
2296
- const sid = String(sdkSessionId || '').trim();
2297
- if (!sid) return { completed: false };
2298
- const startedAt = Date.now();
2299
- while ((Date.now() - startedAt) < timeoutMs) {
2300
- const row = stmts.getSdkDeleteRequestBySessionId.get(sid);
2301
- if (!row) return { completed: true };
2302
- await sleep(SDK_DELETE_POLL_MS);
2303
- }
2304
- return { completed: false };
2328
+ /**
2329
+ * Whether a conversation is still working, which blocks deleting it: a turn
2330
+ * is running (including one waiting on a question card or an approval) or
2331
+ * background tasks are live. Both only count while the session's worker is
2332
+ * alive: a dead worker runs nothing, and the processing rows or task set it
2333
+ * left behind would otherwise make the conversation undeletable. A session
2334
+ * the registry has never seen may still be running a turn, so its
2335
+ * processing rows count.
2336
+ *
2337
+ * A worker's pid decides whether it is alive; its status only when it has
2338
+ * none. A turn that fails marks a worker that is still running 'error',
2339
+ * and it stays so until its next turn, background tasks and all.
2340
+ */
2341
+ function describeConversationActivity(conversationId, workerSessionId) {
2342
+ const worker = sessionWorkerRegistry?.getWorker?.(workerSessionId) || null;
2343
+ const workerAlive = !!worker && (worker.pid
2344
+ ? isPidAlive(worker.pid)
2345
+ : LIVE_WORKER_STATUSES.has(String(worker.status || '').trim().toLowerCase()));
2346
+ const processingCount = Number(countProcessingConversationQueueRows.get(conversationId)?.count || 0);
2347
+ const taskKeys = [...new Set([conversationId, workerSessionId])];
2348
+ const liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
2349
+ return {
2350
+ turnRunning: processingCount > 0 && (workerAlive || !worker),
2351
+ backgroundTaskCount: liveTasks.length,
2352
+ // Tasks the panel shows no Stop button for (Copilot detached shells).
2353
+ unstoppableTaskCount: liveTasks.filter((task) => task?.stoppable === false).length,
2354
+ };
2355
+ }
2356
+
2357
+ function withDeadline(promise, timeoutMs, label) {
2358
+ let timer = null;
2359
+ return Promise.race([
2360
+ promise,
2361
+ new Promise((_, reject) => {
2362
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
2363
+ timer.unref?.();
2364
+ }),
2365
+ ]).finally(() => clearTimeout(timer));
2366
+ }
2367
+
2368
+ /**
2369
+ * The Claude CLI's footprint for one session: its transcript
2370
+ * (`<projectDir>/<nativeSessionId>.jsonl`) and the per-session folder beside
2371
+ * it. Nothing else in the project folder is touched — it holds every other
2372
+ * session of that workspace and the project memory.
2373
+ */
2374
+ function deleteClaudeCliSession({ conversation, runtimeSession }) {
2375
+ const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
2376
+ if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return { deleted: null };
2377
+ const anchors = resolveClaudeSessionRoot({
2378
+ claudeNativeSessionId: nativeSessionId,
2379
+ workspaceRootPath: String(
2380
+ conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '',
2381
+ ).trim(),
2382
+ });
2383
+ if (!anchors?.projectDirPath) return { deleted: null };
2384
+ const projectDir = path.resolve(anchors.projectDirPath);
2385
+ const targets = [anchors.transcriptPath, anchors.sessionRootExists ? anchors.sessionRootPath : '']
2386
+ .filter(Boolean)
2387
+ .map((target) => path.resolve(target))
2388
+ .filter((target) => path.dirname(target) === projectDir
2389
+ && [nativeSessionId, `${nativeSessionId}.jsonl`].includes(path.basename(target)));
2390
+ for (const target of targets) fs.rmSync(target, { recursive: true, force: true });
2391
+ return { deleted: targets.length > 0 };
2392
+ }
2393
+
2394
+ /**
2395
+ * The Copilot session, through the runtime's own deleteSession(). When that
2396
+ * fails the request goes on the SDK delete queue, which a CLI-side consumer
2397
+ * (the extension engine) still works off.
2398
+ */
2399
+ async function deleteCopilotCliSession({ sdkSessionId, conversationId }) {
2400
+ const stateRoot = String(resolveSessionStateRoot?.() || '').trim();
2401
+ const sessionDir = stateRoot && SESSION_FOLDER_ID_PATTERN.test(sdkSessionId) ? path.join(stateRoot, sdkSessionId) : '';
2402
+ // A conversation deleted before its first turn has no session on disk yet
2403
+ // (the runtime would answer "Session file not found").
2404
+ if (sessionDir && !fs.existsSync(sessionDir)) return { deleted: null };
2405
+ if (typeof sdkSessionImportService?.deleteSession !== 'function') return { deleted: null };
2406
+ try {
2407
+ await withDeadline(
2408
+ sdkSessionImportService.deleteSession(sdkSessionId),
2409
+ CLI_SESSION_DELETE_TIMEOUT_MS,
2410
+ 'Copilot deleteSession()',
2411
+ );
2412
+ return { deleted: true };
2413
+ } catch (error) {
2414
+ const reason = String(error?.message || error);
2415
+ if (/Session file not found/i.test(reason)) {
2416
+ // The runtime keeps no session for it, only this session's own
2417
+ // folder (a run that never wrote its session file): nothing to retry.
2418
+ if (sessionDir) fs.rmSync(sessionDir, { recursive: true, force: true });
2419
+ return { deleted: sessionDir ? true : null };
2420
+ }
2421
+ console.warn(`[delete] Copilot session ${shortId(sdkSessionId)} was not deleted: ${reason}`);
2422
+ enqueueSdkDeleteRequest(sdkSessionId, conversationId);
2423
+ return { deleted: false, queued: true };
2424
+ }
2425
+ }
2426
+
2427
+ /** The conversation's CLI-side session, per provider; null where the relay keeps none. */
2428
+ async function deleteCliSession({ conversation, conversationId, sdkSessionId, runtimeSession }) {
2429
+ const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
2430
+ try {
2431
+ if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
2432
+ if (!sdkSessionId) return { deleted: null };
2433
+ if (!provider || provider === 'github' || provider === 'openai') {
2434
+ return await deleteCopilotCliSession({ sdkSessionId, conversationId });
2435
+ }
2436
+ return { deleted: null };
2437
+ } catch (error) {
2438
+ // The conversation is already tombstoned: a file the OS will not let go
2439
+ // of must not leave it half deleted.
2440
+ console.warn(`[delete] CLI session of ${shortId(conversationId)} was not deleted: ${error?.message || error}`);
2441
+ return { deleted: false };
2442
+ }
2305
2443
  }
2306
2444
 
2307
2445
  function finalizeDeletedConversationsForSdkSession(sdkSessionId) {
@@ -2436,7 +2574,7 @@ export function registerSessionsRoutes(app, deps) {
2436
2574
  (stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId),
2437
2575
  );
2438
2576
  const queueRows = db.prepare(`
2439
- SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
2577
+ SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
2440
2578
  FROM queue
2441
2579
  WHERE conversation_id = ?
2442
2580
  `).all(resolvedConversationId);
@@ -3181,7 +3319,7 @@ export function registerSessionsRoutes(app, deps) {
3181
3319
  });
3182
3320
  const dbMessages = stmts.getMessages.all(conversationId);
3183
3321
  const queueRows = db.prepare(`
3184
- SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
3322
+ SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
3185
3323
  FROM queue
3186
3324
  WHERE conversation_id = ?
3187
3325
  `).all(conversationId);
@@ -3583,7 +3721,7 @@ export function registerSessionsRoutes(app, deps) {
3583
3721
  });
3584
3722
  const dbMessages = stmts.getMessages.all(resolvedConversationId);
3585
3723
  const queueRows = db.prepare(`
3586
- SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
3724
+ SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
3587
3725
  FROM queue
3588
3726
  WHERE conversation_id = ?
3589
3727
  `).all(resolvedConversationId);
@@ -3854,15 +3992,30 @@ export function registerSessionsRoutes(app, deps) {
3854
3992
  if (!existing || String(existing.status || '').trim() === 'deleted') {
3855
3993
  return res.status(404).json({ error: 'Conversation not found' });
3856
3994
  }
3857
- const baseDraftUpdatedAtValue = req.body?.baseDraftUpdatedAt ?? req.body?.base_draft_updated_at;
3858
- const comparesDraftVersion = baseDraftUpdatedAtValue !== undefined;
3995
+ // A null base means "I have seen no draft" and conflicts with any versioned
3996
+ // draft — but only from clients that declare the versioned protocol. Older
3997
+ // tabs also send an explicit null (after every keystroke), and a 409 there
3998
+ // would revert their typing, so for them null stays unconditional.
3999
+ const baseDraftUpdatedAtValue = req.body?.baseDraftUpdatedAt !== undefined
4000
+ ? req.body.baseDraftUpdatedAt
4001
+ : req.body?.base_draft_updated_at;
4002
+ const speaksVersionedDraftProtocol = Number(req.body?.draftSyncVersion) >= 2;
4003
+ const comparesDraftVersion = baseDraftUpdatedAtValue !== undefined
4004
+ && (baseDraftUpdatedAtValue !== null || speaksVersionedDraftProtocol);
3859
4005
  const normalizedBaseDraftUpdatedAt = normalizeOptionalIsoTimestamp(baseDraftUpdatedAtValue);
3860
4006
  const suppliedBaseTimestamp = String(baseDraftUpdatedAtValue ?? '').trim();
3861
4007
  if (comparesDraftVersion && suppliedBaseTimestamp && !normalizedBaseDraftUpdatedAt) {
3862
4008
  return res.status(400).json({ error: 'Invalid baseDraftUpdatedAt timestamp' });
3863
4009
  }
3864
4010
  const existingDraftUpdatedAt = normalizeOptionalIsoTimestamp(existing.draft_updated_at);
3865
- if (hasConversationDraftVersionConflict({
4011
+ // A pagehide copy taken while this client's own send was in flight carries
4012
+ // the pre-send base; the send transaction has since moved the version. That
4013
+ // move (recorded with the sender's client id) is the only mismatch it may
4014
+ // pass — a newer draft from anyone else still conflicts.
4015
+ const acceptsOwnSendVersion = req.body?.acceptOwnSend === true
4016
+ && !!senderClientId
4017
+ && String(existing.draft_updated_by_client_id || '') === senderClientId;
4018
+ if (!acceptsOwnSendVersion && hasConversationDraftVersionConflict({
3866
4019
  existingDraftUpdatedAt,
3867
4020
  baseDraftUpdatedAt: normalizedBaseDraftUpdatedAt,
3868
4021
  compareEnabled: comparesDraftVersion,
@@ -3874,6 +4027,7 @@ export function registerSessionsRoutes(app, deps) {
3874
4027
  conflict: true,
3875
4028
  conversationId,
3876
4029
  draftText: String(existing.draft_text || ''),
4030
+ draftAttachments: parseDraftAttachmentsColumn(existing.draft_attachments),
3877
4031
  draftUpdatedAt: existingDraftUpdatedAt,
3878
4032
  draftUpdatedByClientId: existing.draft_updated_by_client_id || null,
3879
4033
  });
@@ -4275,42 +4429,131 @@ export function registerSessionsRoutes(app, deps) {
4275
4429
  }
4276
4430
 
4277
4431
  try {
4278
- const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
4279
- if (!sdkSessionId) {
4280
- stmts.markDeletedSdkSession.run(id, new Date().toISOString());
4281
- cleanupGeneratedImagesForConversation({
4282
- conversationId: id,
4283
- sdkSessionId: null,
4284
- messageRows: stmts.getMessages?.all?.(id) || [],
4285
- parseAttachments,
4286
- hydrateAttachment,
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 });
4432
+ // Session workers are keyed by the SDK session id, which is the
4433
+ // conversation id until a session is bound. Serialized with launches and
4434
+ // relaunches of that session, and with a second delete of it.
4435
+ const workerSessionId = String(existing.sdk_session_id || '').trim() || id;
4436
+ const outcome = await sessionLaunchMutex.runExclusive(
4437
+ workerSessionId,
4438
+ () => deleteConversationNow(id),
4439
+ );
4440
+ return res.status(outcome.statusCode).json(outcome.body);
4308
4441
  } catch (error) {
4309
4442
  console.warn(`[archive] Delete failed for ${id}: ${error?.message || error}`);
4310
4443
  return res.status(500).json({ error: 'Failed to delete conversation' });
4311
4444
  }
4312
4445
  });
4313
4446
 
4447
+ /** DELETE /api/conversation/:id under its session's lock; see the route. */
4448
+ async function deleteConversationNow(id) {
4449
+ const existing = stmts.getConvAnyStatus.get(id);
4450
+ if (!existing) return { statusCode: 200, body: { ok: true, alreadyDeleted: true } };
4451
+ const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
4452
+ const workerSessionId = sdkSessionId || id;
4453
+
4454
+ const activity = describeConversationActivity(id, workerSessionId);
4455
+ if (activity.turnRunning) {
4456
+ return {
4457
+ statusCode: 409,
4458
+ body: {
4459
+ ok: false,
4460
+ error: 'conversation-active',
4461
+ reason: 'turn-running',
4462
+ message: 'This conversation is still working on a reply. Stop it, then delete it.',
4463
+ },
4464
+ };
4465
+ }
4466
+ if (activity.backgroundTaskCount > 0) {
4467
+ return {
4468
+ statusCode: 409,
4469
+ body: {
4470
+ ok: false,
4471
+ error: 'conversation-active',
4472
+ reason: 'background-tasks',
4473
+ // Kill session stops the session's worker, and the delete counts no
4474
+ // tasks for a conversation whose worker is gone.
4475
+ message: activity.unstoppableTaskCount > 0
4476
+ ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then delete it.'
4477
+ : 'Background tasks are still running in this conversation. Stop them in the task panel, then delete it.',
4478
+ },
4479
+ };
4480
+ }
4481
+
4482
+ // A worker and CLI session that another live conversation still uses
4483
+ // stay; only this conversation's own data goes.
4484
+ const sharedWithConversationId = sdkSessionId
4485
+ ? String(findOtherLiveConversationForSdkSession.get(sdkSessionId, id)?.id || '').trim() || null
4486
+ : null;
4487
+ // A Copilot session the relay only imported, and never ran, belongs to
4488
+ // whoever started it in the CLI. Deleting the conversation hides and
4489
+ // tombstones it, and nothing more: the relay started no process for it
4490
+ // (the process scan would find that user's own `copilot --resume`), and
4491
+ // its CLI session stays. Asked before the stop, which removes the registry
4492
+ // entry that shows the relay has run it.
4493
+ const importedOnly = !!sdkSessionId && sdkSessionImportService?.isImportedOnly?.(sdkSessionId) === true;
4494
+
4495
+ // An idle worker is stopped before anything is deleted: the route used to
4496
+ // leave it running (and holding its workspace) until the relay restarted.
4497
+ // Nothing is touched when it will not die.
4498
+ let workerStopped = false;
4499
+ if (!sharedWithConversationId && !importedOnly) {
4500
+ const stopped = await stopIdleWorkspaceRootSession({
4501
+ sdkSessionId: workerSessionId,
4502
+ worker: sessionWorkerRegistry?.getWorker?.(workerSessionId) || null,
4503
+ sessionWorkerSupervisor,
4504
+ sessionWorkerRegistry,
4505
+ sessionWorkerProcessInspector,
4506
+ stopOverrides: sessionWorkerStopOverrides,
4507
+ });
4508
+ if (!stopped.ok) {
4509
+ return {
4510
+ statusCode: 409,
4511
+ body: {
4512
+ ok: false,
4513
+ error: 'worker-stop-failed',
4514
+ message: "Couldn't stop this conversation's session, so nothing was deleted. Try again in a moment.",
4515
+ remainingPids: stopped.remainingPids || [],
4516
+ },
4517
+ };
4518
+ }
4519
+ workerStopped = (stopped.stoppedPids || []).length > 0;
4520
+ // Re-armed after the stop, which can outlast the kill block's grace
4521
+ // window: nothing may respawn a worker for the rows deleted below.
4522
+ sessionWorkerSupervisor?.markKilled?.(workerSessionId);
4523
+ for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
4524
+ }
4525
+
4526
+ const deletedAt = new Date().toISOString();
4527
+ markConversationDeleted.run(deletedAt, id);
4528
+ // Tombstones keep the SDK session importer from bringing it back.
4529
+ stmts.markDeletedSdkSession.run(sdkSessionId || id, deletedAt);
4530
+ if (sdkSessionId && sdkSessionId !== id) stmts.markDeletedSdkSession.run(id, deletedAt);
4531
+
4532
+ const cliSession = sharedWithConversationId || importedOnly
4533
+ ? { deleted: null }
4534
+ : await deleteCliSession({
4535
+ conversation: existing,
4536
+ conversationId: id,
4537
+ sdkSessionId,
4538
+ runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
4539
+ });
4540
+
4541
+ cleanupGeneratedImagesForConversation({
4542
+ conversationId: id,
4543
+ sdkSessionId,
4544
+ messageRows: stmts.getMessages?.all?.(id) || [],
4545
+ parseAttachments,
4546
+ hydrateAttachment,
4547
+ resolveSessionStateRoot,
4548
+ });
4549
+ const orphanedUploads = collectOrphanedUploadsFromConversation(id);
4550
+ hardDeleteConversationRows(id);
4551
+ deleteOrphanedUploads(orphanedUploads);
4552
+ if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
4553
+ io.emit('conversation_deleted', { conversationId: id });
4554
+ return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
4555
+ }
4556
+
4314
4557
  // POST /api/conversation/:id/archive — archive conversation
4315
4558
  app.post('/api/conversation/:id/archive', auth, async (req, res) => {
4316
4559
  const id = String(req.params.id || '').trim();
@@ -24,6 +24,9 @@ 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 { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
28
+ import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
29
+ import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
27
30
  import { isRealPathWithinRoot } from './services/workspace-symlink-guard.mjs';
28
31
  import { applySchema } from './db-schema.mjs';
29
32
  import { createSessionRepository } from './repositories/session-repository.mjs';
@@ -4880,8 +4883,35 @@ const backgroundTaskStore = {
4880
4883
  },
4881
4884
  };
4882
4885
 
4886
+ // The live turn among a conversation's processing rows. Mid-turn steering can
4887
+ // leave several processing at once — the running turn, steered messages the
4888
+ // CLI folded into it (no output of their own), a handed-off row still settling
4889
+ // — so the live turn is the row that produced output most recently; with
4890
+ // nothing to separate them yet, the oldest processing row (the turn a steered
4891
+ // message joins) wins. See live-turn-picker.mjs for why recency, not per-row
4892
+ // stream seq. Returns the whole row so callers get relay_mode/timestamps too.
4893
+ function resolveLiveTurnQueueRow(conversationId) {
4894
+ const rows = stmts.listProcessingQueueByConversation?.all(conversationId)
4895
+ || (stmts.getLatestProcessingQueueByConversation.get(conversationId)
4896
+ ? [stmts.getLatestProcessingQueueByConversation.get(conversationId)]
4897
+ : []);
4898
+ if (!rows.length) return null;
4899
+ if (rows.length === 1) return rows[0];
4900
+ const byId = new Map(rows.map((row) => [String(row.id), row]));
4901
+ const liveId = pickLiveTurnRowId(rows.map((row) => {
4902
+ const output = stmts.getLastOutputAtByQueueMessage?.get({ id: row.id }) || null;
4903
+ return {
4904
+ id: String(row.id),
4905
+ lastOutputAtMs: Date.parse(String(output?.last_output_at || '')) || 0,
4906
+ streamDone: Number(output?.stream_done || 0) === 1,
4907
+ processingAtMs: Date.parse(String(row.processing_at || row.timestamp || '')) || 0,
4908
+ };
4909
+ }));
4910
+ return byId.get(liveId) || rows[0];
4911
+ }
4912
+
4883
4913
  function inFlightStateForConversation(conversationId) {
4884
- const row = stmts.getLatestProcessingQueueByConversation.get(conversationId);
4914
+ const row = resolveLiveTurnQueueRow(conversationId);
4885
4915
  if (!row) return null;
4886
4916
  const streamEvents = relayStreamEventsForQueueMessage(row.id);
4887
4917
  const lastStreamEvent = streamEvents.length ? streamEvents[streamEvents.length - 1] : null;
@@ -4906,17 +4936,17 @@ function inFlightStateForConversation(conversationId) {
4906
4936
  // retry_count like /api/requeue does, but bypasses that route's budget check,
4907
4937
  // so the sweep enforces it here). Minimal mirror of the route-side
4908
4938
  // failQueueMessage: mark failed, insert the assistant failure message, emit.
4909
- function failRecoveredRowTerminally(row, reason) {
4939
+ function failRecoveredRowTerminally(row, reason, { failureRecord = null, failureText: presetText = null } = {}) {
4910
4940
  const now = new Date().toISOString();
4911
4941
  const responseId = uuidv4();
4912
4942
  const retryCount = Number(row?.retry_count || 0) + 1;
4913
- const failureText = `Relay recovery limit reached after ${retryCount} attempts (${reason}). The worker kept dying before completing this turn, so the message was failed to keep the queue moving. Send it again to retry.`;
4943
+ const failureText = presetText || `Relay recovery limit reached after ${retryCount} attempts (${reason}). The worker kept dying before completing this turn, so the message was failed to keep the queue moving. Send it again to retry.`;
4914
4944
  const requestedModel = String(row?.model || '').trim() || null;
4915
4945
  const tx = db.transaction(() => {
4916
4946
  // setFailed is compare-and-set on status (processing/pending/parked): a
4917
4947
  // row that settled between the sweep's listing and this write must not
4918
4948
  // grow an orphan failure bubble on top of its real outcome.
4919
- const result = stmts.setFailed.run(JSON.stringify({
4949
+ const result = stmts.setFailed.run(JSON.stringify(failureRecord || {
4920
4950
  kind: 'recovery-limit',
4921
4951
  error: 'recovery-limit',
4922
4952
  code: 'recovery-limit',
@@ -4940,6 +4970,7 @@ function failRecoveredRowTerminally(row, reason) {
4940
4970
  null,
4941
4971
  requestedModel ? 'user' : 'auto',
4942
4972
  );
4973
+ stmts.setMessageSourceId?.run(row.id, responseId);
4943
4974
  stmts.linkActivityToResponse?.run(responseId, row.id);
4944
4975
  stmts.linkStreamEventsToResponse?.run(responseId, row.id);
4945
4976
  stmts.linkThoughtsToResponse?.run(responseId, row.id);
@@ -4954,7 +4985,20 @@ function failRecoveredRowTerminally(row, reason) {
4954
4985
  message: { role: 'assistant', text: failureText, model: requestedModel || 'unknown', mode: String(row?.relay_mode || '').trim() || null, timestamp: now },
4955
4986
  });
4956
4987
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
4957
- console.warn(`${runtimeLogPrefix()}RECOVERY LIMIT ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} retries=${retryCount} reason=${reason}`);
4988
+ 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}`);
4989
+ }
4990
+
4991
+ // A row whose prompt the worker's runtime already consumed (queue.consumed_at)
4992
+ // is at-most-once: recovery must never requeue it — a re-delivery would run the
4993
+ // prompt twice — so it fails terminally with the steer-settle wording, naming
4994
+ // the provider's runtime the way the workers' own settle wording does.
4995
+ function failConsumedRowTerminally(row, reason) {
4996
+ const providerType = stmts.getRuntimeSessionByConversation?.get?.(row?.conversation_id)?.provider_type;
4997
+ const failureRecord = buildSteerSettleFailure(row, { agentLabel: steerAgentLabelForProvider(providerType) });
4998
+ failRecoveredRowTerminally(row, reason, {
4999
+ failureRecord: { ...failureRecord, error: 'terminal-error', reason: String(reason || 'recovery') },
5000
+ failureText: `${failureRecord.message} Error code: ${failureRecord.stableCode}. ${failureRecord.guidance}`,
5001
+ });
4958
5002
  }
4959
5003
 
4960
5004
  function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso = null } = {}) {
@@ -4966,7 +5010,9 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
4966
5010
  stmts.dropStaleContinuation?.run?.(row.id);
4967
5011
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
4968
5012
  }
4969
- const allRows = stmts.listRecoverableProcessing.all(params);
5013
+ const listedRows = stmts.listRecoverableProcessing.all(params);
5014
+ for (const row of listedRows.filter((entry) => entry?.consumed_at)) failConsumedRowTerminally(row, 'stale-recovery');
5015
+ const allRows = listedRows.filter((entry) => !entry?.consumed_at);
4970
5016
  if (!allRows.length) return [];
4971
5017
  // Enforce the retry budget before requeueing: failing these rows first
4972
5018
  // flips them out of 'processing' so the recovery UPDATE below skips them.
@@ -5772,6 +5818,10 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
5772
5818
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5773
5819
  continue;
5774
5820
  }
5821
+ if (row.consumed_at) {
5822
+ failConsumedRowTerminally(row, `worker-died-${reason}`);
5823
+ continue;
5824
+ }
5775
5825
  if (Number(row.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES) {
5776
5826
  failRecoveredRowTerminally(row, `worker-died-${reason}`);
5777
5827
  continue;
@@ -5814,11 +5864,15 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
5814
5864
  const sid = String(sessionId || '').trim();
5815
5865
  if (!sid) return;
5816
5866
  const owned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
5817
- if (!owned.length) return;
5867
+ // A dead worker's steering snapshot must not keep holding the composer even
5868
+ // when it owned no rows (the hold can outlive them, e.g. a continuation's).
5869
+ const heldSteering = Boolean(sessionWorkerRegistry?.getWorker?.(sid)?.steering);
5870
+ if (!owned.length && !heldSteering) return;
5818
5871
  const timer = setTimeout(() => {
5819
5872
  try {
5820
5873
  if (runtimeShutdownStarted) return;
5821
5874
  if (isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
5875
+ sessionWorkerRegistry?.clearSteering?.(sid);
5822
5876
  const stillOwned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
5823
5877
  if (!stillOwned.length) return;
5824
5878
  console.warn(`${runtimeLogPrefix()}WORKER DIED session=${sid.slice(0, 8)} rows=${stillOwned.length} trigger=socket-close — recovering owned turns`);
@@ -6113,6 +6167,7 @@ const sharedRouteDeps = {
6113
6167
  previewCardsForResponse,
6114
6168
  sanitizeActivityText,
6115
6169
  inFlightStateForConversation,
6170
+ resolveLiveTurnQueueRow,
6116
6171
  backgroundTaskStore,
6117
6172
  // A closure, not the registry itself: this deps object is built before the
6118
6173
  // preview lane is constructed further down the file.
@@ -6523,25 +6578,10 @@ function sanitizeRelayQuestionRequest(requestBody) {
6523
6578
  }
6524
6579
  }
6525
6580
 
6581
+ // The allow-list lives in services/relay-question-context.mjs (unit-tested);
6582
+ // this binds it to the relay's own mode normalizer.
6526
6583
  function sanitizeRelayQuestionContext(rawContext) {
6527
- if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) return null;
6528
- const context = {};
6529
- if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
6530
- context.source = rawContext.source.trim().slice(0, 64);
6531
- }
6532
- if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
6533
- context.rationale = rawContext.rationale.trim().slice(0, 240);
6534
- }
6535
- if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
6536
- context.queueMessageId = rawContext.queueMessageId.trim();
6537
- }
6538
- if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
6539
- context.conversationId = rawContext.conversationId.trim();
6540
- }
6541
- if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
6542
- context.relayMode = normalizeRelayMode(rawContext.relayMode) || DEFAULT_RELAY_MODE;
6543
- }
6544
- return Object.keys(context).length ? context : null;
6584
+ return sanitizeRelayQuestionContextWith(rawContext, { normalizeRelayMode, defaultRelayMode: DEFAULT_RELAY_MODE });
6545
6585
  }
6546
6586
 
6547
6587