@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
@@ -2,25 +2,53 @@
2
2
 
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
- import { fileURLToPath } from 'url';
6
-
7
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
- // Same resolution as server-runtime.mjs: an isolated server (tests, secondary
9
- // installs) points COPILOT_WEB_RELAY_CONFIG elsewhere and must not inherit the
10
- // checkout's live feature flags.
11
- const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
12
- ? path.resolve(String(process.env.COPILOT_WEB_RELAY_CONFIG))
13
- : path.join(__dirname, 'config.json');
14
-
15
- const DEFAULT_FEATURES = Object.freeze({
16
- SESSION_WORKER_ROUTING_ENABLED: false,
17
- SESSION_WORKER_CONTINUATION_ROUTING_ENABLED: false,
18
- SESSION_WORKER_FALLBACK_RESTART_ENABLED: false,
19
- IMAGE_CONVERSATION_CONTINUITY_ENABLED: true,
20
- });
5
+
6
+ // The single registry of relay feature flags. Server resolution, the settings
7
+ // API payload, and the web UI's Features tab all render from these entries, so
8
+ // a new in-development feature only needs a row here plus the code it gates.
9
+ // `noop` marks a flag that is wired into resolution but not yet read by any
10
+ // behavior — shown honestly in the UI so a rollout can be staged later.
11
+ export const FEATURE_REGISTRY = Object.freeze([
12
+ Object.freeze({
13
+ name: 'SESSION_WORKER_ROUTING_ENABLED',
14
+ label: 'Session worker routing',
15
+ default: true,
16
+ noop: false,
17
+ description: 'Give each conversation its own dedicated worker process. The relay routes every message to the worker that owns that conversation, monitors its health, and recovers it if it crashes — a stuck or crashed turn in one conversation no longer stalls the others. When off, all conversations share the legacy polling pipeline.',
18
+ }),
19
+ Object.freeze({
20
+ name: 'SESSION_WORKER_CONTINUATION_ROUTING_ENABLED',
21
+ label: 'Continuation answer routing',
22
+ default: true,
23
+ noop: false,
24
+ description: 'Validate ownership strictly when you answer a clarification question: the relay checks the question’s conversation, continuation id, and owning worker before accepting the answer, so a stale or cross-wired reply is rejected instead of landing in the wrong session. Most useful together with session worker routing.',
25
+ }),
26
+ Object.freeze({
27
+ name: 'SESSION_WORKER_FALLBACK_RESTART_ENABLED',
28
+ label: 'Worker fallback restart (reserved)',
29
+ default: false,
30
+ noop: true,
31
+ description: 'Reserved rollout gate with no effect yet. It is intended to let the relay restart a turn on the fallback pipeline when a session worker cannot be revived. The toggle is shown so the rollout can be staged later without a release; enabling it today changes nothing.',
32
+ }),
33
+ Object.freeze({
34
+ name: 'IMAGE_CONVERSATION_CONTINUITY_ENABLED',
35
+ label: 'Generated-image continuity',
36
+ default: true,
37
+ noop: false,
38
+ description: 'Track the lineage of generated images so a follow-up like “edit this image” continues from the exact image you mean, not just the most recent one. The relay records which operation produced each image and threads edits back through the right source. When off, image follow-ups lose that source linkage.',
39
+ }),
40
+ ]);
41
+
42
+ const DEFAULT_FEATURES = Object.freeze(Object.fromEntries(
43
+ FEATURE_REGISTRY.map((entry) => [entry.name, entry.default]),
44
+ ));
21
45
 
22
46
  const ENV_PREFIX = 'COPILOT_REMOTE_';
23
- const FEATURE_NAMES = Object.freeze(Object.keys(DEFAULT_FEATURES));
47
+ const FEATURE_NAMES = Object.freeze(FEATURE_REGISTRY.map((entry) => entry.name));
48
+
49
+ export function featureSettingKey(name) {
50
+ return `feature_${String(name || '').trim().toLowerCase()}`;
51
+ }
24
52
 
25
53
  function parseBoolean(value) {
26
54
  if (typeof value === 'boolean') return value;
@@ -42,20 +70,6 @@ function normalizeKnownFeatureFlags(input) {
42
70
  return normalized;
43
71
  }
44
72
 
45
- function readFeaturesFromConfig() {
46
- if (!fs.existsSync(CONFIG_PATH)) {
47
- return {};
48
- }
49
-
50
- try {
51
- const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
52
- const configured = parsed && typeof parsed === 'object' ? parsed.features : null;
53
- return normalizeKnownFeatureFlags(configured);
54
- } catch {
55
- return {};
56
- }
57
- }
58
-
59
73
  function readFeaturesFromEnv(env = process.env) {
60
74
  const entries = [];
61
75
  for (const key of FEATURE_NAMES) {
@@ -68,6 +82,17 @@ function readFeaturesFromEnv(env = process.env) {
68
82
  return Object.fromEntries(entries);
69
83
  }
70
84
 
85
+ function readFeaturesFromSettings(readSetting) {
86
+ if (typeof readSetting !== 'function') return {};
87
+ const entries = [];
88
+ for (const key of FEATURE_NAMES) {
89
+ const parsed = parseBoolean(readSetting(featureSettingKey(key)));
90
+ if (parsed === null) continue;
91
+ entries.push([key, parsed]);
92
+ }
93
+ return Object.fromEntries(entries);
94
+ }
95
+
71
96
  export function normalizeFeatureFlags(featureFlags = null) {
72
97
  return Object.freeze({
73
98
  ...DEFAULT_FEATURES,
@@ -75,21 +100,110 @@ export function normalizeFeatureFlags(featureFlags = null) {
75
100
  });
76
101
  }
77
102
 
78
- export function resolveFeatureFlags({
79
- configFeatures = null,
80
- env = process.env,
81
- } = {}) {
103
+ /**
104
+ * The boot-time flag snapshot: registry defaults, overridden by rows in
105
+ * app_settings, overridden by COPILOT_REMOTE_* environment variables. Env stays
106
+ * on top so an isolated test server can pin a flag regardless of what its
107
+ * database says.
108
+ */
109
+ export function resolveBootFeatureFlags({ readSetting, env = process.env } = {}) {
82
110
  return normalizeFeatureFlags({
83
- ...normalizeKnownFeatureFlags(configFeatures),
111
+ ...readFeaturesFromSettings(readSetting),
84
112
  ...readFeaturesFromEnv(env),
85
113
  });
86
114
  }
87
115
 
88
- export const FEATURES = resolveFeatureFlags({
89
- configFeatures: readFeaturesFromConfig(),
90
- });
116
+ /**
117
+ * Per-flag rows for the settings API: what is active in this process, what is
118
+ * stored, whether the environment pins it, and what would be active after a
119
+ * restart. `restartRequired` is the only signal the UI needs to show the
120
+ * restart notice — flags are resolved once at boot and never reloaded.
121
+ */
122
+ export function computeFeatureFlagState({ readSetting, env = process.env, activeFlags = null } = {}) {
123
+ const active = normalizeFeatureFlags(activeFlags);
124
+ const envFlags = readFeaturesFromEnv(env);
125
+ const storedFlags = readFeaturesFromSettings(readSetting);
126
+ return FEATURE_REGISTRY.map((entry) => {
127
+ const stored = Object.hasOwn(storedFlags, entry.name) ? storedFlags[entry.name] : null;
128
+ const envOverride = Object.hasOwn(envFlags, entry.name) ? envFlags[entry.name] : null;
129
+ const effectiveNext = envOverride ?? stored ?? entry.default;
130
+ return {
131
+ name: entry.name,
132
+ label: entry.label,
133
+ description: entry.description,
134
+ default: entry.default,
135
+ noop: entry.noop,
136
+ active: active[entry.name],
137
+ stored,
138
+ envOverride,
139
+ effectiveNext,
140
+ restartRequired: effectiveNext !== active[entry.name],
141
+ };
142
+ });
143
+ }
144
+
145
+ /**
146
+ * One-time boot migration away from a `features` key in config.json: known
147
+ * flags move into app_settings (without clobbering rows that already exist)
148
+ * and the key is stripped from the file.
149
+ *
150
+ * A read that fails is never followed by a write — config.json holds the auth
151
+ * token, so rewriting an unparseable-but-recoverable file would be far worse
152
+ * than running one boot on defaults (see writeCliBinariesToConfigFile in
153
+ * cli-install-service.mjs for the full rationale). The rewrite itself is
154
+ * tmp-file-then-rename in the config directory, mode 0600, for the same
155
+ * partial-write and token-exposure reasons.
156
+ */
157
+ export function migrateLegacyConfigFeatures({
158
+ configPath,
159
+ readSetting,
160
+ writeSetting,
161
+ fsImpl = fs,
162
+ log = (message) => console.warn(message),
163
+ } = {}) {
164
+ const target = String(configPath || '').trim();
165
+ if (!target || typeof readSetting !== 'function' || typeof writeSetting !== 'function') {
166
+ return { migrated: false };
167
+ }
168
+ if (!fsImpl.existsSync(target)) return { migrated: false };
169
+
170
+ let parsed = null;
171
+ try {
172
+ parsed = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
173
+ } catch (error) {
174
+ log(`feature flags: refusing to migrate ${target}: it could not be parsed (${error?.message || error}); flags fall back to defaults + app settings + environment for this boot`);
175
+ return { migrated: false };
176
+ }
177
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !('features' in parsed)) {
178
+ return { migrated: false };
179
+ }
180
+
181
+ const legacy = normalizeKnownFeatureFlags(parsed.features);
182
+ const adopted = [];
183
+ for (const [name, enabled] of Object.entries(legacy)) {
184
+ if (parseBoolean(readSetting(featureSettingKey(name))) !== null) continue;
185
+ writeSetting(featureSettingKey(name), enabled ? '1' : '0');
186
+ adopted.push(name);
187
+ }
188
+
189
+ delete parsed.features;
190
+ const tmpPath = path.join(
191
+ path.dirname(target),
192
+ `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`,
193
+ );
194
+ try {
195
+ fsImpl.writeFileSync(tmpPath, JSON.stringify(parsed, null, 2), { mode: 0o600 });
196
+ try { fsImpl.chmodSync(tmpPath, 0o600); } catch {}
197
+ fsImpl.renameSync(tmpPath, target);
198
+ } catch (error) {
199
+ try { fsImpl.unlinkSync(tmpPath); } catch {}
200
+ log(`feature flags: migrated values to app settings but could not strip the features key from ${target} (${error?.message || error})`);
201
+ return { migrated: true, adopted, stripped: false };
202
+ }
203
+ return { migrated: true, adopted, stripped: true };
204
+ }
91
205
 
92
- export function getSessionWorkerFeatureFlags(featureFlags = FEATURES) {
206
+ export function getSessionWorkerFeatureFlags(featureFlags) {
93
207
  const normalized = normalizeFeatureFlags(featureFlags);
94
208
  return {
95
209
  enabled: normalized.SESSION_WORKER_ROUTING_ENABLED === true,
@@ -98,7 +212,7 @@ export function getSessionWorkerFeatureFlags(featureFlags = FEATURES) {
98
212
  };
99
213
  }
100
214
 
101
- export function isFeatureEnabled(featureName, featureFlags = FEATURES) {
215
+ export function isFeatureEnabled(featureName, featureFlags) {
102
216
  const name = String(featureName || '').trim();
103
217
  if (!name) return false;
104
218
  const normalized = normalizeFeatureFlags(featureFlags);
@@ -8,10 +8,10 @@ import { fileURLToPath } from 'url';
8
8
  import {
9
9
  loadTokenFromConfig,
10
10
  resolveRelayServerUrl,
11
- } from '../../.github/extensions/web-relay/runtime/config-loader.mjs';
12
- import { createApiClient } from '../../.github/extensions/web-relay/runtime/api-client.mjs';
13
- import { createWorkerWebSocketLink } from '../../.github/extensions/web-relay/runtime/worker-websocket-link.mjs';
14
- import { createHeartbeatController } from '../../.github/extensions/web-relay/polling/heartbeat.mjs';
11
+ } from '../../shared/worker-runtime/config-loader.mjs';
12
+ import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
13
+ import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
14
+ import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
15
15
  import { createControlPoller } from '../../shared/control-poller.mjs';
16
16
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
17
17
  import { createGrokTurnRunner } from './grok-turn-runner.mjs';
@@ -131,7 +131,7 @@ async function main() {
131
131
  installWorkerCrashGuard({
132
132
  api,
133
133
  workerName: 'grok-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'}`);
@@ -6,6 +6,7 @@ import {
6
6
  import { buildGrokContextUsage, resolveGrokContextWindow } from './grok-context-usage.mjs';
7
7
  import { extractGrokUsageFromPromptResult, normalizeGrokTurnUsage } from '../services/plan-usage-grok.mjs';
8
8
  import { createPreviewInstructionsProvider } from '../../shared/preview-instructions.mjs';
9
+ import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
9
10
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
10
11
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
11
12
 
@@ -43,6 +44,17 @@ export function buildGrokPlanReadyBoardPayload({
43
44
  };
44
45
  }
45
46
 
47
+ // Delivered rows carry an attemptId the worker echoes on every write so the
48
+ // server can fence out a superseded attempt (HTTP 409 stale_attempt) instead
49
+ // of letting two attempts interleave writes on the same row. The real api
50
+ // client reports the rejection body as a string; tolerate a parsed object too.
51
+ function isStaleAttemptRejection(error) {
52
+ if (error?.status !== 409) return false;
53
+ const detail = error?.detail;
54
+ const text = typeof detail === 'string' ? detail : String(detail?.error || '');
55
+ return text.includes('stale_attempt');
56
+ }
57
+
46
58
  function buildGrokTerminalError(classified, message) {
47
59
  return {
48
60
  kind: 'grok-turn-failed',
@@ -110,6 +122,7 @@ export function createGrokTurnRunner({
110
122
  // prefix like the mode nudge — once per worker, since the Grok session keeps
111
123
  // the history it was told in.
112
124
  let previewInstructionsSent = false;
125
+ let mediaInstructionsSent = false;
113
126
  // The user's max-turn-duration ceiling (0 = no limit), piggybacked on queue
114
127
  // deliveries; null means no delivery has told us yet and the ACP defaults
115
128
  // apply.
@@ -119,6 +132,13 @@ export function createGrokTurnRunner({
119
132
  return waitingForTurn ? String(activeMessage?.id || '') : '';
120
133
  }
121
134
 
135
+ // Crash-guard shape: the attempt id, when known, fences the dying worker's
136
+ // requeue so it cannot yank back a row a successor attempt now owns.
137
+ function getActiveQueueAttempt() {
138
+ if (!waitingForTurn || !activeMessage?.id) return null;
139
+ return { id: String(activeMessage.id), attemptId: activeMessage.attemptId || null };
140
+ }
141
+
122
142
  function isTurnActive() {
123
143
  return waitingForTurn;
124
144
  }
@@ -176,6 +196,7 @@ export function createGrokTurnRunner({
176
196
  mode: message.relayMode || 'agent',
177
197
  text,
178
198
  ...(subagentRunId ? { subagentRunId } : {}),
199
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
179
200
  }).catch(() => {});
180
201
  }
181
202
 
@@ -195,6 +216,7 @@ export function createGrokTurnRunner({
195
216
  text: payload.text,
196
217
  done: payload.done === true,
197
218
  ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
219
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
198
220
  }).catch(() => {});
199
221
  return;
200
222
  }
@@ -207,6 +229,7 @@ export function createGrokTurnRunner({
207
229
  text: payload.text,
208
230
  done: payload.done === true,
209
231
  ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
232
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
210
233
  }).catch(() => {});
211
234
  return;
212
235
  }
@@ -222,6 +245,7 @@ export function createGrokTurnRunner({
222
245
  parentSubagentId: payload.parentSubagentId || undefined,
223
246
  displayName: payload.displayName || undefined,
224
247
  status: payload.status,
248
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
225
249
  }).catch(() => {});
226
250
  return;
227
251
  }
@@ -289,6 +313,7 @@ export function createGrokTurnRunner({
289
313
  mode: message.relayMode || 'agent',
290
314
  text: String(text || ''),
291
315
  done: true,
316
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
292
317
  }).catch(() => {});
293
318
  }
294
319
 
@@ -301,8 +326,18 @@ export function createGrokTurnRunner({
301
326
  modelOrigin: modelOrigin
302
327
  || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
303
328
  ...(terminalError ? { terminalError } : {}),
304
- }).catch(async () => {
305
- await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
329
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
330
+ }).catch(async (error) => {
331
+ // A stale_attempt rejection means the server already handed this row to
332
+ // a newer attempt; requeueing would yank it back, so drop instead.
333
+ if (isStaleAttemptRejection(error)) {
334
+ dbg('response rejected as stale_attempt; dropping superseded turn', message.id);
335
+ return;
336
+ }
337
+ await api('POST', '/api/requeue', {
338
+ messageId: message.id,
339
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
340
+ }).catch(() => {});
306
341
  });
307
342
  }
308
343
 
@@ -339,7 +374,8 @@ export function createGrokTurnRunner({
339
374
  : await Promise.resolve()
340
375
  .then(() => getPreviewInstructions?.())
341
376
  .catch(() => '');
342
- const promptText = [previewBlock, modeNudge, userText].filter(Boolean).join('\n\n');
377
+ const mediaBlock = mediaInstructionsSent ? '' : renderMediaEmbedInstructionBlock();
378
+ const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
343
379
  const pendingNudgedRelayMode = String(message.relayMode || 'agent').trim().toLowerCase();
344
380
 
345
381
  let turn = null;
@@ -378,6 +414,7 @@ export function createGrokTurnRunner({
378
414
  }
379
415
  lastNudgedRelayMode = pendingNudgedRelayMode;
380
416
  if (previewBlock) previewInstructionsSent = true;
417
+ if (mediaBlock) mediaInstructionsSent = true;
381
418
  break;
382
419
  } catch (error) {
383
420
  // A stale agent whose previous prompt never settled reports busy;
@@ -433,7 +470,10 @@ export function createGrokTurnRunner({
433
470
  await publishFinalStream(message, fallbackText);
434
471
  await publishResponse(message, { text: fallbackText, model: responseModel });
435
472
  } else {
436
- await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
473
+ await api('POST', '/api/requeue', {
474
+ messageId: message.id,
475
+ ...(message.attemptId ? { attemptId: message.attemptId } : {}),
476
+ }).catch(() => {});
437
477
  }
438
478
  return true;
439
479
  }
@@ -513,6 +553,7 @@ export function createGrokTurnRunner({
513
553
  return {
514
554
  handlePendingPayload,
515
555
  getActiveQueueMessageId,
556
+ getActiveQueueAttempt,
516
557
  isTurnActive,
517
558
  dispose,
518
559
  setTurnCeilingMs(value) {
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: give steering settle stubs a durable shape the transcript can
5
+ * render on reload.
6
+ *
7
+ * 1. `messages.source_message_id` — the user message an assistant row answers.
8
+ * The link used to live only in `queue.response_message_id`, and the queue
9
+ * keeps just the newest 200 finished rows, so older assistant rows lost
10
+ * their anchor and fell back to response-time ordering: an absorbed
11
+ * (handed-off) reply then sorted below the steered message it hands off to,
12
+ * and the merge never applied. Backfilled from whatever queue rows remain.
13
+ * 2. `messages.resend_of_message_id` — on a user message sent with Resend,
14
+ * the stopped steer it re-sends. Durable, so "Resent" survives reloads and
15
+ * other devices, and the relay can refuse a second Resend of one original.
16
+ * 3. Fold stubs saved as kind='absorbed' become kind='folded'. 'absorbed'
17
+ * tells the client the reply continues through the NEXT user message, so
18
+ * the next ordinary message after a fold was styled as steered.
19
+ *
20
+ * Idempotent: the column is added once, the backfill only fills NULLs, and the
21
+ * kind rewrite matches nothing once applied. Safe to run on every boot.
22
+ *
23
+ * Usage: node server/migrations/0004-steer-settle-markers.mjs [path/to/copilot.db]
24
+ */
25
+
26
+ import { fileURLToPath } from 'url';
27
+ import path from 'path';
28
+ import process from 'process';
29
+ import Database from 'better-sqlite3';
30
+
31
+ import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
32
+
33
+ function tableExists(db, name) {
34
+ return !!db
35
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
36
+ .get(name);
37
+ }
38
+
39
+ function columnNames(db, table) {
40
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
41
+ }
42
+
43
+ export function migrateSteerSettleMarkers(db) {
44
+ if (!tableExists(db, 'messages')) {
45
+ return { applied: false, reason: 'messages table not found', sourceLinksBackfilled: 0, foldStubsRekinded: 0 };
46
+ }
47
+ let columns = columnNames(db, 'messages');
48
+ let columnAdded = false;
49
+ if (!columns.has('source_message_id')) {
50
+ db.exec(`ALTER TABLE messages ADD COLUMN source_message_id TEXT`);
51
+ columnAdded = true;
52
+ }
53
+ if (!columns.has('resend_of_message_id')) {
54
+ db.exec(`ALTER TABLE messages ADD COLUMN resend_of_message_id TEXT`);
55
+ columnAdded = true;
56
+ }
57
+ if (columnAdded) columns = columnNames(db, 'messages');
58
+ let sourceLinksBackfilled = 0;
59
+ let foldStubsRekinded = 0;
60
+ db.transaction(() => {
61
+ // Walk the (small, pruned) queue rather than correlating every message
62
+ // against it.
63
+ if (tableExists(db, 'queue') && columnNames(db, 'queue').has('response_message_id')) {
64
+ const link = db.prepare(`
65
+ UPDATE messages SET source_message_id = ?
66
+ WHERE id = ? AND role = 'assistant' AND source_message_id IS NULL
67
+ `);
68
+ const rows = db.prepare(`
69
+ SELECT id, response_message_id FROM queue
70
+ WHERE response_message_id IS NOT NULL AND response_message_id != ''
71
+ `).all();
72
+ for (const row of rows) {
73
+ sourceLinksBackfilled += link.run(String(row.id), String(row.response_message_id)).changes;
74
+ }
75
+ }
76
+ if (columns.has('kind')) {
77
+ foldStubsRekinded = db.prepare(`
78
+ UPDATE messages SET kind = 'folded'
79
+ WHERE role = 'assistant' AND kind = 'absorbed' AND TRIM(text) = ?
80
+ `).run(STEER_FOLDED_TEXT).changes;
81
+ }
82
+ })();
83
+ return {
84
+ applied: columnAdded || sourceLinksBackfilled > 0 || foldStubsRekinded > 0,
85
+ columnAdded,
86
+ sourceLinksBackfilled,
87
+ foldStubsRekinded,
88
+ };
89
+ }
90
+
91
+ export function migrate(dbPath) {
92
+ const db = new Database(dbPath);
93
+ try {
94
+ return migrateSteerSettleMarkers(db);
95
+ } finally {
96
+ db.close();
97
+ }
98
+ }
99
+
100
+ function defaultDbPath() {
101
+ const here = path.dirname(fileURLToPath(import.meta.url));
102
+ return path.resolve(here, '..', 'data', 'copilot.db');
103
+ }
104
+
105
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
106
+ if (isMain) {
107
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
108
+ try {
109
+ const result = migrate(dbPath);
110
+ console.log(`Migration 0004 on ${dbPath}`);
111
+ console.log(result.applied
112
+ ? ` column added: ${result.columnAdded}; source links backfilled: ${result.sourceLinksBackfilled}; fold stubs re-kinded: ${result.foldStubsRekinded}`
113
+ : ` skipped: ${result.reason || 'nothing to change'}`);
114
+ process.exit(0);
115
+ } catch (err) {
116
+ console.error(`Migration 0004 failed: ${err?.message || err}`);
117
+ process.exit(1);
118
+ }
119
+ }