@oar-sh/oar 0.9.1 → 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 (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -19,7 +19,8 @@ export function buildDefaultConfig({ token = generateAuthToken(), port = 3333, l
19
19
  pollIntervalMs: 3000,
20
20
  processingTimeoutMs: 600000,
21
21
  conversationSessionMode: 'isolated',
22
- updateCheck: true,
22
+ // No updateCheck key: automatic update checking is opt-in from the web
23
+ // UI (app_settings), never a config default — zero telemetry out of the box.
23
24
  };
24
25
  }
25
26
 
@@ -45,13 +46,16 @@ export function relayUrl({ config, lanAddress = null } = {}) {
45
46
  * so a `gh copilot` session is not tied to the unit's lifetime.
46
47
  */
47
48
  export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir }) {
49
+ // systemd units are Linux-only, so the path inside the unit is always
50
+ // posix-joined — host-platform path.join would write backslashes when this
51
+ // template is exercised on Windows (tests; the CLI never writes it there).
48
52
  return [
49
53
  '[Unit]',
50
54
  'Description=OAR — Open Agent Relay',
51
55
  'After=network-online.target',
52
56
  '',
53
57
  '[Service]',
54
- `ExecStart=${nodeBin} ${path.join(packageRoot, 'server', 'server.js')}`,
58
+ `ExecStart=${nodeBin} ${path.posix.join(packageRoot, 'server', 'server.js')}`,
55
59
  `Environment=COPILOT_WEB_RELAY_CONFIG=${configPath}`,
56
60
  `Environment=COPILOT_WEB_RELAY_DATA_DIR=${dataDir}`,
57
61
  `Environment=COPILOT_WEB_RELAY_LOG_DIR=${logDir}`,
@@ -4,6 +4,8 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
 
7
+ import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
8
+
7
9
  const DEFAULT_RELAY_TOOL_GUIDANCE = [
8
10
  '# Relay Tool Guidance',
9
11
  'For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.',
@@ -125,13 +127,38 @@ function stripAttachmentPromptArtifacts(text) {
125
127
  .trim();
126
128
  }
127
129
 
130
+ // The once-per-worker media-embed guidance rides ahead of the mode marker on
131
+ // the Cursor/Grok prompt paths, so it is dropped before the prefix-anchored
132
+ // marker patterns run. Machine-injected verbatim, never user-authored.
133
+ const MEDIA_EMBED_BLOCK_PATTERN = new RegExp(
134
+ whitespaceFlexiblePattern(renderMediaEmbedInstructionBlock()),
135
+ 'gi',
136
+ );
137
+
138
+ // A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
139
+ // runtime stored, right after any mode marker. Matched by its stable opening
140
+ // words, because the browser copy of this module cannot import shared/.
141
+ const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
142
+
143
+ // The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
144
+ // zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
145
+ // it is matched on its own, and only where it guards a "/".
146
+ const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
147
+
148
+ function stripLeadingRelayNotes(text) {
149
+ const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
150
+ return stripped.trim() ? stripped.trim() : String(text || '').trim();
151
+ }
152
+
128
153
  export function stripRelayPromptContext(text, relayMode = '', attachments = []) {
129
- const value = stripAttachmentPromptArtifacts(text);
154
+ const value = stripAttachmentPromptArtifacts(text)
155
+ .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
156
+ .trim();
130
157
  if (!value) return '';
131
158
  const patterns = buildPromptPrefixPatterns(relayMode);
132
159
  for (const pattern of patterns) {
133
160
  const stripped = value.replace(pattern, '').trim();
134
- if (stripped && stripped !== value) return stripped;
161
+ if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped);
135
162
  }
136
- return value;
163
+ return stripLeadingRelayNotes(value);
137
164
  }
@@ -0,0 +1,46 @@
1
+ // The `context` block a relay question card carries: who asked, why, for which
2
+ // queue row, and the few presentation hints the card renders from. Workers
3
+ // send it on POST /api/relay-question; it is stored inside the question's
4
+ // request envelope and served back to the client. Anything not listed here is
5
+ // dropped, so a new hint the client reads must be added here too (the
6
+ // multi-select flag was once silently stripped this way).
7
+
8
+ /**
9
+ * @param {unknown} rawContext
10
+ * @param {{ normalizeRelayMode?: (mode: string) => string | null, defaultRelayMode?: string }} [options]
11
+ */
12
+ export function sanitizeRelayQuestionContext(rawContext, {
13
+ normalizeRelayMode = (mode) => String(mode || '').trim().toLowerCase() || null,
14
+ defaultRelayMode = 'agent',
15
+ } = {}) {
16
+ if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) return null;
17
+ const context = {};
18
+ if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
19
+ context.source = rawContext.source.trim().slice(0, 64);
20
+ }
21
+ if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
22
+ context.rationale = rawContext.rationale.trim().slice(0, 240);
23
+ }
24
+ if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
25
+ context.queueMessageId = rawContext.queueMessageId.trim();
26
+ }
27
+ if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
28
+ context.conversationId = rawContext.conversationId.trim();
29
+ }
30
+ if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
31
+ context.relayMode = normalizeRelayMode(rawContext.relayMode) || defaultRelayMode;
32
+ }
33
+ // The card renders checkmarks instead of one-shot buttons when several
34
+ // answers are allowed (Claude's AskUserQuestion `multiSelect`, or the
35
+ // Copilot worker's wording heuristic). Only an explicit `true` is kept.
36
+ if (rawContext.multiSelect === true) context.multiSelect = true;
37
+ // Providers whose ask tool cannot always say so (Copilot) mark their choice
38
+ // cards with this: the card then offers a "Select several" switch, so the
39
+ // user can pick several answers even when the model did not flag it.
40
+ if (rawContext.allowMultiSelect === true) context.allowMultiSelect = true;
41
+ // Claude's short question label ("Workers", "Visibility").
42
+ if (typeof rawContext.header === 'string' && rawContext.header.trim()) {
43
+ context.header = rawContext.header.trim().slice(0, 120);
44
+ }
45
+ return Object.keys(context).length ? context : null;
46
+ }
@@ -71,17 +71,69 @@ export function createSdkSessionImportService({
71
71
  parseSessionEventsToMessages,
72
72
  replaceRetrievableHistory,
73
73
  ensureRuntimeSessionBinding,
74
+ // Live evidence that the relay runs (or ran) a worker for this session in
75
+ // the current process — queue rows alone miss a worker between turns.
76
+ hasRelayExecutionSignal = () => false,
74
77
  logger = console,
75
78
  } = {}) {
76
79
  if (!db || !stmts || typeof createClient !== 'function') throw new Error('SDK session importer requires database, statements, and a client factory');
77
- let runtime = null;
80
+ let runtimeCreation = null;
78
81
  let activeRun = null;
82
+ let closing = false;
79
83
  const countConversationMessages = db.prepare(`
80
84
  SELECT COUNT(*) AS count
81
85
  FROM messages
82
86
  WHERE conversation_id = ?
83
87
  `);
84
88
 
89
+ // Ownership ledger (audit #19). The importer creates a runtime binding for
90
+ // every conversation it imports, so "a binding exists" cannot double as "the
91
+ // relay executes this conversation" — that reading froze every imported
92
+ // session after its first import. The ledger's origin column records who the
93
+ // binding belongs to; relay evidence flips it durably below.
94
+ const getImportOwnership = db.prepare(`
95
+ SELECT status, origin
96
+ FROM sdk_session_imports
97
+ WHERE sdk_session_id = ?
98
+ `);
99
+ // Any queue row referencing the session — including done/failed ones that
100
+ // have not been pruned yet — proves the relay queued a turn for it.
101
+ const getQueueEvidence = db.prepare(`
102
+ SELECT 1 AS present
103
+ FROM queue
104
+ WHERE conversation_id = ? OR owner_sdk_session_id = ?
105
+ LIMIT 1
106
+ `);
107
+ // Durable one-way flip: once the relay owns a session, pruned queue rows or
108
+ // a restart must not hand it back to the importer.
109
+ const markImportRelayOwned = db.prepare(`
110
+ UPDATE sdk_session_imports
111
+ SET origin = 'relay', updated_at = ?
112
+ WHERE sdk_session_id = ? AND (origin IS NULL OR origin != 'relay')
113
+ `);
114
+ const markImportOriginImported = db.prepare(`
115
+ UPDATE sdk_session_imports
116
+ SET origin = 'imported'
117
+ WHERE sdk_session_id = ? AND (origin IS NULL OR origin = 'imported')
118
+ `);
119
+
120
+ function relayOwnsSession(sdkSessionId) {
121
+ const ledger = getImportOwnership.get(sdkSessionId) || null;
122
+ if (ledger?.origin === 'relay') return true;
123
+ const executed = !!getQueueEvidence.get(sdkSessionId, sdkSessionId)
124
+ || hasRelayExecutionSignal(sdkSessionId) === true;
125
+ if (executed) {
126
+ markImportRelayOwned.run(new Date().toISOString(), sdkSessionId);
127
+ return true;
128
+ }
129
+ // origin = 'imported' is only ever written by a completed import, so it is
130
+ // the one proof the binding belongs to the importer (and it survives a
131
+ // later failed refresh attempt). Anything else — no ledger row, no origin —
132
+ // means the relay created the binding (conversation bootstrap,
133
+ // session-sync) and owns the history.
134
+ return ledger?.origin !== 'imported';
135
+ }
136
+
85
137
  const upsertConversation = db.prepare(`
86
138
  INSERT INTO conversations (id, title, sdk_session_id, configured_workspace_root_path, runtime_workspace_root_path, created_at, updated_at)
87
139
  VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -94,8 +146,21 @@ export function createSdkSessionImportService({
94
146
  `);
95
147
 
96
148
  async function getRuntime() {
97
- if (!runtime) runtime = await createClient();
98
- return runtime;
149
+ // Refusing after dispose() is what stops a mid-shutdown import from
150
+ // resurrecting a fresh SDK client the server would never tear down.
151
+ if (closing) throw new Error('SDK session importer is shutting down');
152
+ // Callers share the start in flight: parallel deletes, or a delete during
153
+ // the startup import, each started a runtime of their own, and only the
154
+ // last one was ever disposed. A start that fails is dropped, so the next
155
+ // call tries again.
156
+ if (!runtimeCreation) {
157
+ const creation = Promise.resolve().then(() => createClient());
158
+ runtimeCreation = creation;
159
+ creation.catch(() => {
160
+ if (runtimeCreation === creation) runtimeCreation = null;
161
+ });
162
+ }
163
+ return runtimeCreation;
99
164
  }
100
165
 
101
166
  function isTombstoned(sdkSessionId) {
@@ -136,6 +201,16 @@ export function createSdkSessionImportService({
136
201
  const workspaceRoot = text(metadata.workspaceRootPath || metadata.workspace_root_path || metadata.cwd) || null;
137
202
  const title = sessionTitle(session, messages);
138
203
  db.transaction(() => {
204
+ // The relay may have queued a turn while this import was reading SDK
205
+ // events; overwriting now would replace relay history with the raw
206
+ // transcript. Re-checked inside the transaction so the decision and the
207
+ // write are one unit. (The durable origin flip happens in the caller —
208
+ // an UPDATE here would roll back with the throw.)
209
+ if (getQueueEvidence.get(sdkSessionId, sdkSessionId) || hasRelayExecutionSignal(sdkSessionId) === true) {
210
+ const error = new Error('Relay queued a turn during import');
211
+ error.code = 'relay-owned';
212
+ throw error;
213
+ }
139
214
  upsertConversation.run(sdkSessionId, title, sdkSessionId, workspaceRoot, workspaceRoot, createdAt, updatedAt);
140
215
  ensureRuntimeSessionBinding(sdkSessionId, null, updatedAt, sdkSessionId);
141
216
  replaceRetrievableHistory(sdkSessionId, messages);
@@ -147,12 +222,15 @@ export function createSdkSessionImportService({
147
222
  now,
148
223
  sdkSessionId,
149
224
  );
225
+ // The binding above belongs to the import, not to relay execution.
226
+ markImportOriginImported.run(sdkSessionId);
150
227
  })();
151
228
  }
152
229
 
153
230
  async function importSession(session, { force = false } = {}) {
154
231
  const sdkSessionId = sessionIdOf(session);
155
232
  if (!sdkSessionId) return { status: 'skipped', category: 'unchanged', reason: 'missing-session-id' };
233
+ if (closing) return { sdkSessionId, status: 'skipped', category: 'unchanged', reason: 'importer-closing' };
156
234
  if (isTombstoned(sdkSessionId)) return { sdkSessionId, status: 'skipped', category: 'tombstoned', reason: 'tombstoned' };
157
235
  // Sessions the relay created to execute an existing conversation's turns
158
236
  // are vehicles, not conversations: their history already lives in the
@@ -165,15 +243,16 @@ export function createSdkSessionImportService({
165
243
  // The SDK-engine workers (Copilot SDK, Claude, Cursor, Grok) run a
166
244
  // conversation's turns in a CLI session that shares the conversation's OWN
167
245
  // id — the exact equality the guard above reads as "external". A runtime
168
- // binding for that conversation means the relay executes it and already
169
- // owns its history: importing would overwrite relay messages with the raw
170
- // runtime transcript, instruction preambles included (burn-in incident
171
- // 2026-08-31: "[Relay mode: autopilot] …" surfaced as user bubbles and
172
- // conversation titles after a restart). This also protects an imported
173
- // conversation the user later CONTINUED in the relay — from that moment
174
- // the relay's history is authoritative, not the CLI transcript.
246
+ // binding for that conversation used to be read as relay ownership
247
+ // outright, but the importer itself creates a binding on every completed
248
+ // import — that reading made the first import the last (audit #19). The
249
+ // binding still matters (importing over relay history reproduces the
250
+ // burn-in incident 2026-08-31: "[Relay mode: autopilot] …" surfaced as
251
+ // user bubbles and titles after a restart), so ownership now comes from
252
+ // the ledger: only a binding the importer did NOT account for, or actual
253
+ // relay activity (a queued turn, a live worker), blocks the import.
175
254
  const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(sdkSessionId) || null;
176
- if (runtimeSession) {
255
+ if (runtimeSession && relayOwnsSession(sdkSessionId)) {
177
256
  return { sdkSessionId, status: 'skipped', category: 'relay-owned', reason: 'relay-execution-session' };
178
257
  }
179
258
  // Same protection when a live conversation already claims this session id
@@ -202,11 +281,20 @@ export function createSdkSessionImportService({
202
281
  persistCompletedImport({ sdkSessionId, session, messages });
203
282
  return { sdkSessionId, status: 'completed', category: claimed.category, messageCount: messages.length };
204
283
  } catch (error) {
205
- stmts.failSdkSessionImport.run(new Date().toISOString(), boundedError(error), sdkSessionId);
284
+ const now = new Date().toISOString();
285
+ if (error?.code === 'relay-owned') {
286
+ // The relay claimed the session mid-import. Flip ownership durably and
287
+ // release the claim so the ledger row is not stuck in 'processing'.
288
+ markImportRelayOwned.run(now, sdkSessionId);
289
+ stmts.failSdkSessionImport.run(now, boundedError(error), sdkSessionId);
290
+ return { sdkSessionId, status: 'skipped', category: 'relay-owned', reason: 'relay-claimed-during-import' };
291
+ }
292
+ stmts.failSdkSessionImport.run(now, boundedError(error), sdkSessionId);
206
293
  return { sdkSessionId, status: 'failed', category: 'failed', error: boundedError(error) };
207
294
  } finally {
208
- try { await resumed?.stop?.(); } catch {}
209
- try { await resumed?.dispose?.(); } catch {}
295
+ // SDK 1.0.13's CopilotSession cleanup API; the previous optional
296
+ // stop()/dispose() calls matched nothing and leaked every resume.
297
+ try { await resumed?.disconnect?.(); } catch {}
210
298
  }
211
299
  }
212
300
 
@@ -229,6 +317,9 @@ export function createSdkSessionImportService({
229
317
  const sessions = await normalizeEvents(await client.client.listSessions());
230
318
  summary.listed = sessions.length;
231
319
  for (const session of sessions) {
320
+ // A shutdown mid-sweep must stop cleanly between sessions instead of
321
+ // racing dispose() for the runtime.
322
+ if (closing) break;
232
323
  const result = await importSession(session);
233
324
  summary[result.category] = Number(summary[result.category] || 0) + 1;
234
325
  }
@@ -248,6 +339,34 @@ export function createSdkSessionImportService({
248
339
  return activeRun;
249
340
  }
250
341
 
342
+ /**
343
+ * Remove a Copilot session from the runtime's own store, for a conversation
344
+ * deleted in the relay. Goes through the SDK rather than the session-state
345
+ * folder so the runtime's bookkeeping stays consistent. Throws when the
346
+ * runtime cannot do it; the caller decides what that means.
347
+ */
348
+ async function deleteSession(sdkSessionId) {
349
+ const sid = text(sdkSessionId);
350
+ if (!sid) throw new Error('Missing session id');
351
+ const client = await getRuntime();
352
+ if (typeof client?.client?.deleteSession !== 'function') {
353
+ throw new Error('deleteSession() is unavailable in this Copilot runtime');
354
+ }
355
+ await client.client.deleteSession(sid);
356
+ }
357
+
358
+ /**
359
+ * Whether the relay only imported this session and never ran it: the ledger
360
+ * says the importer brought it in, and no queued turn or relay worker says
361
+ * the relay has run it since. Continuing an imported conversation does not
362
+ * touch the ledger, so that evidence is checked here too, and it flips the
363
+ * ledger to 'relay' for good, as it does on import.
364
+ */
365
+ function isImportedOnly(sdkSessionId) {
366
+ const sid = text(sdkSessionId);
367
+ return !!sid && !relayOwnsSession(sid);
368
+ }
369
+
251
370
  async function refreshConversation(conversation) {
252
371
  const sdkSessionId = text(conversation?.sdk_session_id || conversation?.sdkSessionId || conversation?.id);
253
372
  if (!sdkSessionId || isTombstoned(sdkSessionId)) {
@@ -262,9 +381,18 @@ export function createSdkSessionImportService({
262
381
  runStartupImport,
263
382
  importSession,
264
383
  refreshConversation,
384
+ deleteSession,
385
+ isImportedOnly,
265
386
  async dispose() {
266
- const current = runtime;
267
- runtime = null;
387
+ // Flag first: getRuntime() must refuse before the client goes away, or a
388
+ // concurrent import finds no runtime and starts a replacement while the
389
+ // server is exiting.
390
+ closing = true;
391
+ try { await activeRun; } catch {}
392
+ // A start still in flight is waited for, so its runtime is disposed too.
393
+ const creation = runtimeCreation;
394
+ runtimeCreation = null;
395
+ const current = await creation?.catch(() => null);
268
396
  await current?.dispose?.();
269
397
  },
270
398
  };
@@ -65,14 +65,29 @@ export function createSdkSessionSyncService(db) {
65
65
  WHERE id = ?
66
66
  `);
67
67
 
68
+ // Rekeying a placeholder must carry every live queue row with it: a
69
+ // processing/parked row left under the placeholder id would be orphaned the
70
+ // moment the real session id takes over (audit #22). attempt_id is
71
+ // deliberately untouched — owner migration must not invalidate the Phase 1
72
+ // attempt fence on an in-flight row.
68
73
  const migrateQueueOwnerSessionId = db.prepare(`
69
74
  UPDATE queue
70
75
  SET owner_sdk_session_id = ?
71
- WHERE status = 'pending'
76
+ WHERE status IN ('pending', 'processing', 'parked')
72
77
  AND owner_sdk_session_id = ?
73
78
  AND conversation_id = ?
74
79
  `);
75
80
 
81
+ // Once a session syncs, the relay drives it: its ledger row (if it was ever
82
+ // imported) flips to relay ownership so the startup import sweep can never
83
+ // again overwrite relay history with the raw CLI transcript. One-way by
84
+ // design; rows that never existed are a no-op.
85
+ const markImportRelayOwned = db.prepare(`
86
+ UPDATE sdk_session_imports
87
+ SET origin = 'relay', updated_at = ?
88
+ WHERE sdk_session_id = ? AND (origin IS NULL OR origin != 'relay')
89
+ `);
90
+
76
91
  const insertRuntimeSession = db.prepare(runtimeSessionsSupportProviders
77
92
  ? `
78
93
  INSERT INTO runtime_sessions (
@@ -85,24 +100,27 @@ export function createSdkSessionSyncService(db) {
85
100
  ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)
86
101
  `);
87
102
 
88
- const syncSessionTx = db.transaction((sdkSessionIdRaw, conversationIdRaw) => {
89
- const sdkSessionId = normalizeId(sdkSessionIdRaw);
90
- const conversationId = normalizeId(conversationIdRaw);
91
- const nowIso = new Date().toISOString();
92
-
103
+ // Every conflict check, no writes. syncSessionTx runs it immediately before
104
+ // mutating; the session-sync route additionally runs it (via validateBinding,
105
+ // with requireConversation off — workspace learning may legitimately create
106
+ // the conversation afterwards) BEFORE workspace learning is allowed to touch
107
+ // any table, so a doomed request is vetoed while everything is pristine
108
+ // (audit #21).
109
+ function assertNoBindingConflicts(sdkSessionId, conversationId, { requireConversation = true } = {}) {
93
110
  if (!sdkSessionId || !conversationId) {
94
111
  throw makeError('Missing sdk_session_id or conversation_id', 400);
95
112
  }
96
113
 
97
- const conversation = getConversation.get(conversationId);
98
- if (!conversation || String(conversation.status || '').trim() === 'deleted') {
114
+ const conversation = getConversation.get(conversationId) || null;
115
+ const conversationMissing = !conversation || String(conversation.status || '').trim() === 'deleted';
116
+ if (conversationMissing && requireConversation) {
99
117
  throw makeError('Conversation not found', 404);
100
118
  }
101
119
 
102
- const existingConversationSdkSessionId = normalizeId(conversation.sdk_session_id);
103
- const placeholderConversationBinding = existingConversationSdkSessionId
120
+ const existingConversationSdkSessionId = conversationMissing ? '' : normalizeId(conversation.sdk_session_id);
121
+ const placeholderConversationBinding = !!(existingConversationSdkSessionId
104
122
  && existingConversationSdkSessionId === conversationId
105
- && existingConversationSdkSessionId !== sdkSessionId;
123
+ && existingConversationSdkSessionId !== sdkSessionId);
106
124
  if (existingConversationSdkSessionId && existingConversationSdkSessionId !== sdkSessionId && !placeholderConversationBinding) {
107
125
  throw makeError(
108
126
  `Conversation ${conversationId} is already bound to SDK session ${existingConversationSdkSessionId}`,
@@ -146,14 +164,6 @@ export function createSdkSessionSyncService(db) {
146
164
  );
147
165
  }
148
166
 
149
- updateConversationSdkSession.run(sdkSessionId, nowIso, conversationId);
150
- if (placeholderConversationBinding) {
151
- migrateQueueOwnerSessionId.run(sdkSessionId, conversationId, conversationId);
152
- }
153
-
154
- let runtimeSessionId = null;
155
- let createdRuntimeSession = false;
156
-
157
167
  if (runtimeSessionByConversation) {
158
168
  const currentSdkSessionId = normalizeId(runtimeSessionByConversation.sdk_session_id);
159
169
  if (currentSdkSessionId && currentSdkSessionId !== sdkSessionId && currentSdkSessionId !== conversationId) {
@@ -162,18 +172,47 @@ export function createSdkSessionSyncService(db) {
162
172
  409,
163
173
  );
164
174
  }
175
+ }
176
+
177
+ return {
178
+ placeholderConversationBinding,
179
+ runtimeSessionByConversation,
180
+ runtimeSessionBySdkSessionId,
181
+ };
182
+ }
183
+
184
+ // ORDERING CONTRACT (audit #22): the queue dequeue path reads
185
+ // owner_sdk_session_id to route pending work, so the placeholder→real rekey
186
+ // below must be visible before the next dequeue considers this
187
+ // conversation's rows. Both run on the same better-sqlite3 connection and
188
+ // this whole rekey is one synchronous transaction, so a dequeue can never
189
+ // observe a half-rekeyed state; callers must keep the in-memory registry
190
+ // rekey immediately after this transaction commits (see the session-sync
191
+ // route) rather than deferring it past other queue work.
192
+ const syncSessionTx = db.transaction((sdkSessionIdRaw, conversationIdRaw) => {
193
+ const sdkSessionId = normalizeId(sdkSessionIdRaw);
194
+ const conversationId = normalizeId(conversationIdRaw);
195
+ const nowIso = new Date().toISOString();
196
+
197
+ const {
198
+ placeholderConversationBinding,
199
+ runtimeSessionByConversation,
200
+ runtimeSessionBySdkSessionId,
201
+ } = assertNoBindingConflicts(sdkSessionId, conversationId, { requireConversation: true });
202
+
203
+ updateConversationSdkSession.run(sdkSessionId, nowIso, conversationId);
204
+ let migratedQueueRows = 0;
205
+ if (placeholderConversationBinding) {
206
+ migratedQueueRows = Number(migrateQueueOwnerSessionId.run(sdkSessionId, conversationId, conversationId).changes || 0);
207
+ }
208
+
209
+ let runtimeSessionId = null;
210
+ let createdRuntimeSession = false;
165
211
 
212
+ if (runtimeSessionByConversation) {
166
213
  updateRuntimeSessionSdkSession.run(conversationId, sdkSessionId, nowIso, runtimeSessionByConversation.id);
167
214
  runtimeSessionId = runtimeSessionByConversation.id;
168
215
  } else if (runtimeSessionBySdkSessionId) {
169
- const currentConversationId = normalizeId(runtimeSessionBySdkSessionId.conversation_id);
170
- if (currentConversationId && currentConversationId !== conversationId) {
171
- throw makeError(
172
- `SDK session ${sdkSessionId} is already bound to conversation ${currentConversationId}`,
173
- 409,
174
- );
175
- }
176
-
177
216
  updateRuntimeSessionSdkSession.run(conversationId, sdkSessionId, nowIso, runtimeSessionBySdkSessionId.id);
178
217
  runtimeSessionId = runtimeSessionBySdkSessionId.id;
179
218
  } else {
@@ -191,11 +230,23 @@ export function createSdkSessionSyncService(db) {
191
230
  );
192
231
  }
193
232
 
233
+ markImportRelayOwned.run(nowIso, sdkSessionId);
234
+ if (conversationId !== sdkSessionId) {
235
+ // A conversation originally imported under its own session id keeps a
236
+ // ledger row under that id; rebinding to a new CLI session means the
237
+ // relay owns that history now too.
238
+ markImportRelayOwned.run(nowIso, conversationId);
239
+ }
240
+
194
241
  return {
195
242
  conversationId,
196
243
  sdkSessionId,
197
244
  runtimeSessionId,
198
245
  createdRuntimeSession,
246
+ migratedQueueRows,
247
+ // Non-null when this sync rekeyed a placeholder binding: the caller must
248
+ // rekey any in-memory worker-registry entry from this id to sdkSessionId.
249
+ placeholderSdkSessionId: placeholderConversationBinding ? conversationId : null,
199
250
  };
200
251
  });
201
252
 
@@ -203,5 +254,11 @@ export function createSdkSessionSyncService(db) {
203
254
  syncSession({ sdk_session_id, conversation_id }) {
204
255
  return syncSessionTx(sdk_session_id, conversation_id);
205
256
  },
257
+ // Read-only conflict probe for the session-sync route's validate-first
258
+ // ordering. Missing conversations pass — workspace learning creates them.
259
+ validateBinding({ sdk_session_id, conversation_id }) {
260
+ assertNoBindingConflicts(normalizeId(sdk_session_id), normalizeId(conversation_id), { requireConversation: false });
261
+ return true;
262
+ },
206
263
  };
207
264
  }