@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
@@ -4,8 +4,7 @@
4
4
  // control polling, activity channels) but executes turns through the Copilot
5
5
  // SDK's headless runtime instead of the `copilot` TUI under a PTY.
6
6
  //
7
- // Dormant until phase 3 registers a worker kind for it. Run it by hand against
8
- // a live relay with:
7
+ // Run it by hand against a live relay with:
9
8
  //
10
9
  // COPILOT_WEB_RELAY_CONFIG=server/config.json \
11
10
  // COPILOT_WORKSPACE_ROOT=/path/to/workspace \
@@ -17,10 +16,14 @@ import { fileURLToPath } from 'url';
17
16
  import {
18
17
  loadTokenFromConfig,
19
18
  resolveRelayServerUrl,
20
- } from '../../.github/extensions/web-relay/runtime/config-loader.mjs';
21
- import { createApiClient } from '../../.github/extensions/web-relay/runtime/api-client.mjs';
22
- import { createWorkerWebSocketLink } from '../../.github/extensions/web-relay/runtime/worker-websocket-link.mjs';
23
- import { createHeartbeatController } from '../../.github/extensions/web-relay/polling/heartbeat.mjs';
19
+ } from '../../shared/worker-runtime/config-loader.mjs';
20
+ import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
21
+ import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
22
+ import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
23
+ import {
24
+ createRunnerLinkBridge,
25
+ failSettlingRowsOnShutdown,
26
+ } from '../../shared/worker-runtime/runner-link-wiring.mjs';
24
27
  import { createControlPoller } from '../../shared/control-poller.mjs';
25
28
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
26
29
  import {
@@ -69,8 +72,29 @@ async function main() {
69
72
  api,
70
73
  sdkSessionId,
71
74
  abortAckNote: 'copilot session aborted',
75
+ // A subagent lane's Stop. Lanes are keyed on the envelope agentId, which
76
+ // is also the runtime's task id, so `rpc.tasks.cancel` is tried; the
77
+ // runtime refuses ids it does not track (then the control answers "not
78
+ // supported" and the whole-turn Stop remains). Background agents have no
79
+ // lane — their Stop is the task panel's. (Late-bound: controls only poll
80
+ // while a turn is live.)
81
+ onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTask(subagentRunId),
72
82
  dbg,
73
83
  });
84
+ // The relay's *Background task timeout* slider (0 = unlimited) rides every
85
+ // delivery payload, exactly as for the Claude worker: background agents and
86
+ // shells now have cards and a Stop, so the slider governs them too and a cap
87
+ // expiry CANCELS them rather than forgetting them. The env var is an
88
+ // emergency override only.
89
+ const backgroundTaskTimeoutOverrideMs = readOptionalMs('COPILOT_SDK_RELAY_BACKGROUND_TASK_TIMEOUT_MS');
90
+ let backgroundTaskTimeoutMs = backgroundTaskTimeoutOverrideMs ?? 0;
91
+ // The runner reports every flip of its delivery gate; the bridge turns them
92
+ // into the link's worker.unready / worker.ready frames, so a message queued
93
+ // while a question card is open is held in the relay queue and steers into
94
+ // the resumed turn one round trip after the answer.
95
+ const linkBridge = createRunnerLinkBridge();
96
+ // Late-bound: the heartbeat controller is built after the runner.
97
+ let heartbeat = null;
74
98
  const turnRunner = createCopilotSdkSessionRunner({
75
99
  api,
76
100
  sdkSessionId,
@@ -79,30 +103,42 @@ async function main() {
79
103
  controlPoller,
80
104
  idleShutdownMs: readOptionalMs('COPILOT_SDK_RELAY_IDLE_SHUTDOWN_MS'),
81
105
  turnStallTimeoutMs: readOptionalMs('COPILOT_SDK_RELAY_TURN_STALL_TIMEOUT_MS'),
82
- // How long live detached shells alone may hold the runtime open (0 = no
83
- // limit). Deliberately NOT the relay's `background_task_timeout_minutes`
84
- // slider that rides every delivery payload: that slider defaults to
85
- // 0/unlimited and governs Claude's background tasks, which the composer
86
- // lists and can stop. A Copilot detached shell has neither, so consuming it
87
- // would make "a forgotten shell pins a runtime forever" the default with
88
- // nothing in the UI to reveal it. The runner's own 30-minute cap applies
89
- // unless this says otherwise.
90
- ...(() => {
91
- const override = readOptionalMs('COPILOT_SDK_RELAY_BACKGROUND_TASK_TIMEOUT_MS');
92
- return override === undefined ? {} : { getBackgroundTaskTimeoutMs: () => override };
93
- })(),
106
+ // How long a deferred model switch may wait for its drain before the
107
+ // explicit selection fails the row (default 10s).
108
+ modelSwitchTimeoutMs: readOptionalMs('COPILOT_SDK_RELAY_MODEL_SWITCH_TIMEOUT_MS'),
109
+ getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
110
+ // A readiness flip is a hold starting or ending (question card, approval,
111
+ // compaction): the composer's steering snapshot changed with it.
112
+ onDeliveryReadinessChange: (ready) => {
113
+ linkBridge.onDeliveryReadinessChange(ready);
114
+ heartbeat?.requestPulse();
115
+ },
116
+ // A message became (or stopped being) cancellable: report it now rather
117
+ // than on the next 10 s heartbeat (coalesced by the controller).
118
+ onCancellableChange: () => heartbeat?.requestPulse(),
119
+ canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
94
120
  dbg,
95
121
  });
96
122
 
97
123
  let heartbeatTimer = null;
98
- const heartbeat = createHeartbeatController({
124
+ heartbeat = createHeartbeatController({
99
125
  api,
100
126
  pollMs: HEARTBEAT_MS,
101
127
  getSessionReady: () => true,
102
128
  getHeartbeatTimer: () => heartbeatTimer,
103
129
  setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
104
130
  getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
105
- getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
131
+ // The runner reports { id, attemptId } entries; the heartbeat's claim
132
+ // payload is id-only (its String() coercion would mangle an object), while
133
+ // the crash guard below takes the entries whole so its requeues stay
134
+ // fenced to this attempt.
135
+ getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
136
+ // The composer's steering snapshot (Steer / Queue + the hold reason, the
137
+ // un-steerable rows) and the consumed steers whose settle the runner gave
138
+ // up on — the relay fails those itself, never re-running them.
139
+ getSteeringState: () => turnRunner.steeringState(),
140
+ getSettleFailed: () => turnRunner.getSettleFailed(),
141
+ onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
106
142
  });
107
143
 
108
144
  const wsLink = createWorkerWebSocketLink({
@@ -112,8 +148,22 @@ async function main() {
112
148
  getSessionReady: () => true,
113
149
  getSessionId: () => sdkSessionId,
114
150
  getPid: () => process.pid,
151
+ // Mid-turn steering opt-in: the link keeps signalling readiness while a
152
+ // delivery is in flight as long as the runner can absorb another message
153
+ // into the live turn, and withdraws it while the runner holds.
154
+ getSteeringReady: () => turnRunner.canAcceptSteering(),
155
+ getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
156
+ // A relay that just (re)started knows nothing of this worker until it
157
+ // heartbeats; the composer's steering snapshot rides that beat.
158
+ onServerHello: () => heartbeat.requestPulse(),
115
159
  onDeliver: async (pending, reason) => {
116
160
  dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
161
+ // Slider changes reach a running worker on its next delivery; an
162
+ // explicit env override pins the value regardless.
163
+ const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
164
+ if (backgroundTaskTimeoutOverrideMs === undefined && Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
165
+ backgroundTaskTimeoutMs = deliveredTimeout;
166
+ }
117
167
  try {
118
168
  return await turnRunner.handlePendingPayload(pending);
119
169
  } catch (error) {
@@ -122,19 +172,38 @@ async function main() {
122
172
  }
123
173
  },
124
174
  onControl: (control) => {
125
- // Background-task controls have no Copilot SDK equivalent yet: the
126
- // runtime's `session.background_tasks_changed` carries an empty payload
127
- // (23 of them fire during a single bash call), so there is no task id to
128
- // stop. Logged rather than silently dropped.
129
- dbg('worker control ignored (unsupported by the SDK worker)', String(control?.type || '(none)'));
175
+ const type = String(control?.type || '').trim();
176
+ if (type === 'cancel_pushed_message') {
177
+ // Un-steer: pull a pushed-but-unconsumed message back out of the
178
+ // runtime's queue and cancel its row.
179
+ void turnRunner.cancelPushedMessage(control?.messageId).catch((error) => {
180
+ dbg('un-steer failed', error?.message || String(error));
181
+ });
182
+ return;
183
+ }
184
+ if (type === 'stop_background_task') {
185
+ // The task panel's per-card Stop → rpc.tasks.cancel.
186
+ const taskId = String(control?.taskId || '').trim();
187
+ dbg('stop background task control', taskId);
188
+ void turnRunner.stopBackgroundTask(taskId).catch((error) => {
189
+ dbg('stop background task failed', error?.message || String(error));
190
+ });
191
+ return;
192
+ }
193
+ dbg('worker control ignored (unsupported by the SDK worker)', type || '(none)');
130
194
  },
131
195
  });
196
+ linkBridge.attach(wsLink);
132
197
 
133
198
  const shutdown = async (signal) => {
134
199
  dbg(`shutting down (${signal})`);
135
200
  try { wsLink.stop(); } catch {}
136
201
  try { heartbeat.stopHeartbeat(); } catch {}
137
202
  try { controlPoller.stop(); } catch {}
203
+ // Rows whose prompt the runtime consumed and whose settle marker has not
204
+ // landed are failed terminally, never left for dead-worker recovery to
205
+ // requeue (that would run the prompt twice). Bounded.
206
+ try { await failSettlingRowsOnShutdown({ api, runner: turnRunner }); } catch {}
138
207
  // Bounded, like the Cursor worker: stopping the runtime is an RPC, and a
139
208
  // hung stop must never make the process ignore the supervisor's SIGTERM.
140
209
  try {
@@ -9,10 +9,10 @@ import { fileURLToPath } from 'url';
9
9
  import {
10
10
  loadTokenFromConfig,
11
11
  resolveRelayServerUrl,
12
- } from '../../.github/extensions/web-relay/runtime/config-loader.mjs';
13
- import { createApiClient } from '../../.github/extensions/web-relay/runtime/api-client.mjs';
14
- import { createWorkerWebSocketLink } from '../../.github/extensions/web-relay/runtime/worker-websocket-link.mjs';
15
- import { createHeartbeatController } from '../../.github/extensions/web-relay/polling/heartbeat.mjs';
12
+ } from '../../shared/worker-runtime/config-loader.mjs';
13
+ import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
+ import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
+ import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
16
  import { createControlPoller } from '../../shared/control-poller.mjs';
17
17
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
18
18
  import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
@@ -131,7 +131,7 @@ async function main() {
131
131
  installWorkerCrashGuard({
132
132
  api,
133
133
  workerName: 'cursor-session-worker',
134
- getActiveQueueMessageIds: () => [turnRunner.getActiveQueueMessageId()],
134
+ getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
135
135
  });
136
136
 
137
137
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
@@ -17,6 +17,7 @@ import {
17
17
  cursorSubagentRosterFingerprint,
18
18
  } from './cursor-subagent-roster.mjs';
19
19
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
20
+ import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
20
21
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
21
22
  import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
22
23
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
@@ -86,6 +87,17 @@ export function buildCursorPlanReadyBoardPayload({
86
87
  };
87
88
  }
88
89
 
90
+ // Delivered rows carry an attemptId the worker echoes on every write so the
91
+ // server can fence out a superseded attempt (HTTP 409 stale_attempt) instead
92
+ // of letting two attempts interleave writes on the same row. The real api
93
+ // client reports the rejection body as a string; tolerate a parsed object too.
94
+ function isStaleAttemptRejection(error) {
95
+ if (error?.status !== 409) return false;
96
+ const detail = error?.detail;
97
+ const text = typeof detail === 'string' ? detail : String(detail?.error || '');
98
+ return text.includes('stale_attempt');
99
+ }
100
+
89
101
  /**
90
102
  * Execute one delivered relay turn against the Cursor SDK, streaming
91
103
  * normalized events into the relay's activity channels and publishing the
@@ -131,6 +143,10 @@ export function createCursorTurnRunner({
131
143
  // Mirrors the Copilot extension's lastPromptedRelayMode: full mode
132
144
  // instructions ride on the first message of a mode and on mode changes only.
133
145
  let lastNudgedRelayMode = '';
146
+ // Like Grok's once-per-worker capability guidance: the Cursor SDK has no
147
+ // system-prompt seam either, so the media-embed hint rides the first prompt
148
+ // and the session's history keeps it known from then on.
149
+ let mediaGuidanceSent = false;
134
150
 
135
151
  const bridge = createAskUserBridge({
136
152
  api,
@@ -177,6 +193,13 @@ export function createCursorTurnRunner({
177
193
  return waitingForTurn ? String(activeMessage?.id || '') : '';
178
194
  }
179
195
 
196
+ // Crash-guard shape: the attempt id, when known, fences the dying worker's
197
+ // requeue so it cannot yank back a row a successor attempt now owns.
198
+ function getActiveQueueAttempt() {
199
+ if (!waitingForTurn || !activeMessage?.id) return null;
200
+ return { id: String(activeMessage.id), attemptId: activeMessage.attemptId || null };
201
+ }
202
+
180
203
  function isTurnActive() {
181
204
  return waitingForTurn;
182
205
  }
@@ -269,6 +292,7 @@ export function createCursorTurnRunner({
269
292
  mode: message.relayMode || 'agent',
270
293
  text,
271
294
  ...(subagentRunId ? { subagentRunId } : {}),
295
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
272
296
  }).catch(() => {});
273
297
  }
274
298
 
@@ -291,6 +315,7 @@ export function createCursorTurnRunner({
291
315
  text: payload.text,
292
316
  done: payload.done === true,
293
317
  ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
318
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
294
319
  }).catch(() => {});
295
320
  return;
296
321
  }
@@ -303,6 +328,7 @@ export function createCursorTurnRunner({
303
328
  text: payload.text,
304
329
  done: payload.done === true,
305
330
  ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
331
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
306
332
  }).catch(() => {});
307
333
  return;
308
334
  }
@@ -318,6 +344,7 @@ export function createCursorTurnRunner({
318
344
  parentSubagentId: payload.parentSubagentId || undefined,
319
345
  displayName: payload.displayName || undefined,
320
346
  status: payload.status,
347
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
321
348
  }).catch(() => {});
322
349
  return;
323
350
  }
@@ -396,6 +423,7 @@ export function createCursorTurnRunner({
396
423
  mode: message.relayMode || 'agent',
397
424
  text: String(text || ''),
398
425
  done: true,
426
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
399
427
  }).catch(() => {});
400
428
  }
401
429
 
@@ -408,8 +436,18 @@ export function createCursorTurnRunner({
408
436
  modelOrigin: modelOrigin
409
437
  || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
410
438
  ...(terminalError ? { terminalError } : {}),
411
- }).catch(async () => {
412
- await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
439
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
440
+ }).catch(async (error) => {
441
+ // A stale_attempt rejection means the server already handed this row to
442
+ // a newer attempt; requeueing would yank it back, so drop instead.
443
+ if (isStaleAttemptRejection(error)) {
444
+ dbg('response rejected as stale_attempt; dropping superseded turn', message.id);
445
+ return;
446
+ }
447
+ await api('POST', '/api/requeue', {
448
+ messageId: message.id,
449
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
450
+ }).catch(() => {});
413
451
  });
414
452
  }
415
453
 
@@ -456,7 +494,10 @@ export function createCursorTurnRunner({
456
494
  await ensureAgentHandle(message, model);
457
495
  const userMessage = buildUserMessageImpl(message);
458
496
  const modeNudge = cursorModeNudge(message.relayMode, lastNudgedRelayMode);
459
- if (modeNudge) userMessage.text = [modeNudge, userMessage.text].filter(Boolean).join('\n\n');
497
+ const mediaGuidance = mediaGuidanceSent ? '' : renderMediaEmbedInstructionBlock();
498
+ if (modeNudge || mediaGuidance) {
499
+ userMessage.text = [mediaGuidance, modeNudge, userMessage.text].filter(Boolean).join('\n\n');
500
+ }
460
501
  // Committed only once the send reached the transport (before the loop's
461
502
  // break below) — a turn that dies before delivery must re-inject the
462
503
  // nudge next time, or a mode change would be silently swallowed.
@@ -558,6 +599,7 @@ export function createCursorTurnRunner({
558
599
  // harmless duplicate, so under-committing is the safe direction.
559
600
  if (!aborted && !abortController.signal.aborted) {
560
601
  lastNudgedRelayMode = pendingNudgedRelayMode;
602
+ if (mediaGuidance) mediaGuidanceSent = true;
561
603
  }
562
604
  break;
563
605
  } catch (error) {
@@ -622,7 +664,10 @@ export function createCursorTurnRunner({
622
664
  await publishFinalStream(message, fallbackText);
623
665
  await publishResponse(message, { text: fallbackText, model: responseModel });
624
666
  } else {
625
- await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
667
+ await api('POST', '/api/requeue', {
668
+ messageId: message.id,
669
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
670
+ }).catch(() => {});
626
671
  }
627
672
  return true;
628
673
  }
@@ -711,6 +756,7 @@ export function createCursorTurnRunner({
711
756
  return {
712
757
  handlePendingPayload,
713
758
  getActiveQueueMessageId,
759
+ getActiveQueueAttempt,
714
760
  isTurnActive,
715
761
  dispose,
716
762
  };
@@ -9,6 +9,7 @@
9
9
  // final schema either way.
10
10
  import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-workspace-roots-path-key.mjs';
11
11
  import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
12
+ import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
12
13
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
13
14
 
14
15
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -168,7 +169,8 @@ export const SCHEMA_SQL = `
168
169
  source_started_at TEXT,
169
170
  source_modified_at TEXT,
170
171
  updated_at TEXT NOT NULL,
171
- last_error TEXT
172
+ last_error TEXT,
173
+ origin TEXT
172
174
  );
173
175
 
174
176
  CREATE INDEX IF NOT EXISTS idx_sdk_session_imports_status
@@ -241,6 +243,7 @@ export const SCHEMA_SQL = `
241
243
  context_limit_tokens INTEGER,
242
244
  long_context_limit_tokens INTEGER,
243
245
  pricing_json TEXT,
246
+ metadata_json TEXT,
244
247
  enabled INTEGER NOT NULL DEFAULT 1,
245
248
  sort_order INTEGER NOT NULL DEFAULT 0,
246
249
  updated_at TEXT NOT NULL
@@ -572,6 +575,19 @@ if (!sdkSessionImportColumns.includes('source_started_at')) {
572
575
  if (!sdkSessionImportColumns.includes('source_modified_at')) {
573
576
  db.exec(`ALTER TABLE sdk_session_imports ADD COLUMN source_modified_at TEXT`);
574
577
  }
578
+ if (!sdkSessionImportColumns.includes('origin')) {
579
+ // 'imported' = the importer created the conversation's runtime binding, so a
580
+ // later re-import/refresh is safe; 'relay' = the relay queued or executed a
581
+ // turn, making the relay's history authoritative (re-importing would replace
582
+ // it with the raw CLI transcript, instruction preambles included). Completed
583
+ // rows predating the column were written by the importer (relay-born
584
+ // conversations never get a ledger row — the import guards skip them before
585
+ // claiming), so backfilling 'imported' restores refreshability; any that the
586
+ // relay has since continued are re-flagged 'relay' from queue/worker evidence
587
+ // on their next import attempt.
588
+ db.exec(`ALTER TABLE sdk_session_imports ADD COLUMN origin TEXT`);
589
+ db.exec(`UPDATE sdk_session_imports SET origin = 'imported' WHERE status = 'completed'`);
590
+ }
575
591
 
576
592
  const queueColumns = db.prepare(`PRAGMA table_info(queue)`).all().map((c) => c.name);
577
593
  if (!queueColumns.includes('model')) {
@@ -622,12 +638,32 @@ if (!queueColumns.includes('parked_at')) {
622
638
  if (!queueColumns.includes('parked_target_session_id')) {
623
639
  db.exec(`ALTER TABLE queue ADD COLUMN parked_target_session_id TEXT`);
624
640
  }
641
+ if (!queueColumns.includes('consumed_at')) {
642
+ // Set when the Claude CLI has provably taken a row's prompt (a steer still
643
+ // pending when the turn it was pushed into finished). A consumed row is
644
+ // at-most-once: every recovery path fails it terminally instead of
645
+ // requeueing it, because a re-delivery would run the prompt a second time.
646
+ db.exec(`ALTER TABLE queue ADD COLUMN consumed_at TEXT`);
647
+ }
625
648
  if (!queueColumns.includes('kind')) {
626
649
  // 'continuation' rows are synthetic turns for the Claude worker's
627
650
  // background-task continuations: born 'processing', never deliverable, and
628
651
  // torn down (not requeued) when their worker disappears.
629
652
  db.exec(`ALTER TABLE queue ADD COLUMN kind TEXT`);
630
653
  }
654
+ if (!queueColumns.includes('attempt_id')) {
655
+ // One processing attempt = one attempt_id: minted on every pending->processing
656
+ // transition (and at continuation birth), cleared by every requeue/recovery,
657
+ // kept on terminal rows so forensics can name the attempt that finalized.
658
+ // Writers that echo it are fenced; writers that omit it ride legacy semantics
659
+ // until the extension engine is retired.
660
+ db.exec(`ALTER TABLE queue ADD COLUMN attempt_id TEXT`);
661
+ }
662
+ if (!queueColumns.includes('continuation_op_id')) {
663
+ // Worker-supplied idempotency key for POST /api/continuation-turn: a retry
664
+ // after a lost response must find the row it already created, not mint a twin.
665
+ db.exec(`ALTER TABLE queue ADD COLUMN continuation_op_id TEXT`);
666
+ }
631
667
 
632
668
  // recent_workspace_roots gained a case-normalized primary key (path_key). The
633
669
  // CREATE TABLE IF NOT EXISTS above only covers fresh databases, so upgrade an
@@ -652,7 +688,20 @@ db.exec(`UPDATE queue SET relay_mode = 'agent' WHERE relay_mode IS NULL OR relay
652
688
  db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_next_attempt ON queue(status, next_attempt_at, timestamp)`);
653
689
  db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_owner_pending ON queue(status, owner_sdk_session_id, next_attempt_at, timestamp)`);
654
690
  db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_parked_release ON queue(status, parked_transaction_id, parked_target_session_id, parked_at, timestamp)`);
691
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_queue_continuation_op ON queue(continuation_op_id) WHERE continuation_op_id IS NOT NULL`);
655
692
  migrateImageConversationSchema(db);
693
+ // After the queue's response_message_id exists: the source-link backfill
694
+ // reads it. Not boot-critical — every reader of its columns tolerates their
695
+ // absence — so a failure (e.g. SQLITE_BUSY) is logged and retried next boot
696
+ // instead of keeping the relay down.
697
+ try {
698
+ const steerSettleMarkersMigration = migrateSteerSettleMarkers(db);
699
+ if (steerSettleMarkersMigration.foldStubsRekinded) {
700
+ console.log(`[steering] re-kinded ${steerSettleMarkersMigration.foldStubsRekinded} fold stub(s) as 'folded'`);
701
+ }
702
+ } catch (error) {
703
+ console.warn(`[steering] migration 0004 failed; retrying on next boot: ${error?.message || error}`);
704
+ }
656
705
 
657
706
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
658
707
  if (runtimeSessionColumns.length) {
@@ -821,6 +870,11 @@ if (relayQuestionColumns.length && !relayQuestionColumns.includes('structured_an
821
870
  if (relayQuestionColumns.length && !relayQuestionColumns.includes('request_schema')) {
822
871
  db.exec(`ALTER TABLE relay_questions ADD COLUMN request_schema TEXT`);
823
872
  }
873
+ if (relayQuestionColumns.length && !relayQuestionColumns.includes('attempt_id')) {
874
+ // Stamped from the queue row at creation so stale-question cleanup can cancel
875
+ // cards left behind by a superseded processing attempt.
876
+ db.exec(`ALTER TABLE relay_questions ADD COLUMN attempt_id TEXT`);
877
+ }
824
878
  db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_questions_continuation ON relay_questions(continuation_id, continuation_question_id, status, created_at)`);
825
879
 
826
880
  db.exec(`
@@ -929,4 +983,10 @@ if (modelVariantColumns.length && !modelVariantColumns.includes('long_context_li
929
983
  if (modelVariantColumns.length && !modelVariantColumns.includes('pricing_json')) {
930
984
  db.exec(`ALTER TABLE model_variants ADD COLUMN pricing_json TEXT`);
931
985
  }
986
+ // Per-model catalog metadata (vendor, picker category, real context window,
987
+ // runtime-reported reasoning efforts, ...) persisted with the variant rows so a
988
+ // restart serves it before the next worker/discovery snapshot arrives.
989
+ if (modelVariantColumns.length && !modelVariantColumns.includes('metadata_json')) {
990
+ db.exec(`ALTER TABLE model_variants ADD COLUMN metadata_json TEXT`);
991
+ }
932
992
  }