@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
@@ -9,15 +9,16 @@ 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 { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
18
18
  import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
19
19
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
20
20
  import { createClaudeSessionRunner } from './claude-session-process.mjs';
21
+ import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
21
22
 
22
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
24
  const HEARTBEAT_MS = 10_000;
@@ -82,6 +83,9 @@ async function main() {
82
83
  onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTaskByToolUseId(subagentRunId),
83
84
  dbg,
84
85
  });
86
+ const linkBridge = createRunnerLinkBridge();
87
+ // Late-bound: the heartbeat controller is built after the runner.
88
+ let heartbeat = null;
85
89
  const turnRunner = createClaudeSessionRunner({
86
90
  api,
87
91
  sdkSessionId,
@@ -102,18 +106,37 @@ async function main() {
102
106
  getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
103
107
  getAutoCompactWindow: () => autoCompactWindow,
104
108
  getThinking: () => thinking,
109
+ // A readiness flip is a hold starting or ending (question card,
110
+ // compaction, a turn-opening delivery): the steering snapshot changed
111
+ // with it, so it goes out now rather than on the next 10 s heartbeat.
112
+ onDeliveryReadinessChange: (ready) => {
113
+ linkBridge.onDeliveryReadinessChange(ready);
114
+ heartbeat?.requestPulse();
115
+ },
116
+ canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
105
117
  dbg,
106
118
  });
107
119
 
108
120
  let heartbeatTimer = null;
109
- const heartbeat = createHeartbeatController({
121
+ heartbeat = createHeartbeatController({
110
122
  api,
111
123
  pollMs: HEARTBEAT_MS,
112
124
  getSessionReady: () => true,
113
125
  getHeartbeatTimer: () => heartbeatTimer,
114
126
  setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
115
127
  getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
116
- getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
128
+ // The runner reports { id, attemptId } entries; the heartbeat's claim
129
+ // payload is id-only (its String() coercion would mangle an object), while
130
+ // the crash guard below takes the entries whole so its requeues stay
131
+ // fenced to this attempt.
132
+ getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
133
+ // Composer steering snapshot. Holds reach the relay within a pulse window
134
+ // (onDeliveryReadinessChange above) and a relay that just came up gets it
135
+ // on its first hello; the periodic beat only backstops the rest.
136
+ getSteeringState: () => turnRunner.steeringState(),
137
+ // Consumed rows the runner could not fail itself; the relay fails them.
138
+ getSettleFailed: () => turnRunner.getSettleFailed(),
139
+ onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
117
140
  });
118
141
 
119
142
  const wsLink = createWorkerWebSocketLink({
@@ -121,6 +144,18 @@ async function main() {
121
144
  token,
122
145
  dbg,
123
146
  getSessionReady: () => true,
147
+ // Mid-turn steering: while a turn is running and the runner can absorb
148
+ // another message into it, the link keeps signalling readiness, so a
149
+ // message sent while Claude is thinking is pushed into the live turn
150
+ // instead of waiting for the result (docs/plans/2026-09-19-claude-mid-
151
+ // turn-steering.md).
152
+ getSteeringReady: () => turnRunner.canAcceptSteering(),
153
+ // A hold (open question card, compaction, adoption) suppresses readiness
154
+ // even between deliveries — a delivery drawn into it is only handed back.
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(),
124
159
  getSessionId: () => sdkSessionId,
125
160
  getPid: () => process.pid,
126
161
  onDeliver: async (pending, reason) => {
@@ -154,20 +189,30 @@ async function main() {
154
189
  dbg('unknown worker control ignored', type || '(none)');
155
190
  },
156
191
  });
192
+ linkBridge.attach(wsLink);
157
193
 
158
- const shutdown = (signal) => {
194
+ let shuttingDown = false;
195
+ const shutdown = async (signal) => {
196
+ if (shuttingDown) return;
197
+ shuttingDown = true;
159
198
  dbg(`shutting down (${signal})`);
160
199
  try { wsLink.stop(); } catch {}
161
200
  try { heartbeat.stopHeartbeat(); } catch {}
162
201
  try { controlPoller.stop(); } catch {}
202
+ // Consumed steers still settling must fail, not be left for dead-worker
203
+ // recovery to requeue and run twice (same 2 s cap as the crash guard).
204
+ try {
205
+ const failed = await failSettlingRowsOnShutdown({ api, runner: turnRunner, timeoutMs: 2_000 });
206
+ if (failed) dbg(`failed ${failed} settling row(s) before exit`);
207
+ } catch {}
163
208
  // Hard-close the persistent CLI process; a worker teardown cannot save
164
209
  // its background tasks, and the orphan-notification replay on the next
165
210
  // resume reports whatever they left behind.
166
211
  try { turnRunner.shutdown({ graceful: false }); } catch {}
167
212
  process.exit(0);
168
213
  };
169
- process.on('SIGTERM', () => shutdown('SIGTERM'));
170
- process.on('SIGINT', () => shutdown('SIGINT'));
214
+ process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
215
+ process.on('SIGINT', () => { void shutdown('SIGINT'); });
171
216
  installWorkerCrashGuard({
172
217
  api,
173
218
  workerName: 'claude-session-worker',
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
3
3
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
4
+ import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
4
5
 
5
6
  const PLAN_BOARD_ACTIONS = [
6
7
  { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
@@ -8,6 +9,26 @@ const PLAN_BOARD_ACTIONS = [
8
9
  { id: 'exit_only', label: 'Stop here', mode: 'agent' },
9
10
  ];
10
11
 
12
+ /**
13
+ * The attempt-fencing echo for a row's write bodies. Every publish that names
14
+ * a `messageId` also names the attempt it belongs to, so the relay can refuse
15
+ * writes from a superseded attempt (a requeued row re-delivered elsewhere).
16
+ * Omitted entirely when the row carries no attempt id (a pre-fencing relay).
17
+ */
18
+ export function attemptFields(message) {
19
+ return message?.attemptId ? { attemptId: message.attemptId } : {};
20
+ }
21
+
22
+ /** A 409 whose detail names `stale_attempt`: this attempt was superseded. */
23
+ export function isStaleAttemptError(error) {
24
+ if (Number(error?.status) !== 409) return false;
25
+ const detail = typeof error?.detail === 'string' ? error.detail : JSON.stringify(error?.detail || '');
26
+ return detail.includes('stale_attempt');
27
+ }
28
+
29
+ export { buildSteerSettleFailure, steerSettleFailureText };
30
+ export const STEER_SETTLE_FAILED_TEXT = steerSettleFailureText('folded');
31
+
11
32
  export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}) {
12
33
  const summary = String(planText || '').trim();
13
34
  if (!summary) return null;
@@ -38,6 +59,11 @@ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}
38
59
  * (delivered or background-continuation) through one publisher.
39
60
  */
40
61
  export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRuns = null } = {}) {
62
+ function drainWorkflowRuns({ terminal = false } = {}) {
63
+ if (terminal || typeof takeWorkflowRuns !== 'function') return null;
64
+ return takeWorkflowRuns();
65
+ }
66
+
41
67
  async function postActivity(message, text, subagentRunId = null, metadata = null) {
42
68
  if (!text) return;
43
69
  await api('POST', '/api/activity', {
@@ -47,6 +73,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
47
73
  text,
48
74
  ...(subagentRunId ? { subagentRunId } : {}),
49
75
  ...(metadata && typeof metadata === 'object' ? { metadata } : {}),
76
+ ...attemptFields(message),
50
77
  }).catch(() => {});
51
78
  }
52
79
 
@@ -69,6 +96,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
69
96
  text: payload.text,
70
97
  done: payload.done === true,
71
98
  ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
99
+ ...attemptFields(message),
72
100
  }).catch(() => {});
73
101
  return;
74
102
  }
@@ -81,6 +109,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
81
109
  text: payload.text,
82
110
  done: payload.done === true,
83
111
  ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
112
+ ...attemptFields(message),
84
113
  }).catch(() => {});
85
114
  return;
86
115
  }
@@ -96,6 +125,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
96
125
  parentSubagentId: payload.parentSubagentId || undefined,
97
126
  displayName: payload.displayName || undefined,
98
127
  status: payload.status,
128
+ ...attemptFields(message),
99
129
  }).catch(() => {});
100
130
  return;
101
131
  }
@@ -155,31 +185,90 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
155
185
  mode: message.relayMode || 'agent',
156
186
  text: String(text || ''),
157
187
  done: true,
188
+ ...attemptFields(message),
158
189
  }).catch(() => {});
159
190
  }
160
191
 
161
- async function publishResponse(message, { text, model, terminalError = null, modelOrigin }) {
192
+ /**
193
+ * Returns the outcome: 'published' (the relay took it), 'stale' (another
194
+ * attempt owns the row), 'failed' (not taken and NOT requeued), or
195
+ * 'requeued'. A `kind` marks a row whose prompt the CLI already consumed —
196
+ * 'absorbed' (the reply continues through the next, steered message),
197
+ * 'folded' (answered by the turn it was steered into) or 'stopped' (steered
198
+ * into a turn the user stopped) — and such a row is never
199
+ * requeued, since re-delivery would run the prompt a second time; the
200
+ * caller owns retrying it. `requeueOnFailure: false` opts any other publish
201
+ * into the same contract.
202
+ */
203
+ async function publishResponse(message, {
204
+ text,
205
+ model,
206
+ terminalError = null,
207
+ modelOrigin,
208
+ absorbed = false,
209
+ kind = null,
210
+ requeueOnFailure = true,
211
+ // Ids of steered queue rows pushed into the turn this response finishes
212
+ // and still unsettled: the relay marks them consumed in the same
213
+ // transaction, so no recovery path ever re-runs them (at-most-once even
214
+ // if this worker dies before settling them).
215
+ consumedSteerIds = null,
216
+ // A caller retrying one publish drains the settled workflow digests ONCE
217
+ // and passes them to every attempt, so a failed attempt cannot lose them.
218
+ workflowRuns: presetWorkflowRuns,
219
+ }) {
220
+ const responseKind = kind || (absorbed ? 'absorbed' : null);
162
221
  // Final digests of workflows that settled since the last response ride the
163
222
  // next successful response publish — the summarizing turn is the natural
164
223
  // transcript anchor for the "Finished background task" card. Terminal
165
224
  // failures skip the drain so the digests stay buffered for a later turn;
166
225
  // if this POST fails (→ requeue) or the process dies before any response
167
226
  // publishes, the drained digests are lost — acceptable v1.
168
- const workflowRuns = (!terminalError && typeof takeWorkflowRuns === 'function')
169
- ? takeWorkflowRuns()
170
- : null;
171
- await api('POST', '/api/response', {
172
- messageId: message.id,
173
- conversationId: message.conversationId,
174
- text: String(text || ''),
175
- model: model || null,
176
- modelOrigin: modelOrigin
177
- || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
178
- ...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
179
- ...(terminalError ? { terminalError } : {}),
180
- }).catch(async () => {
181
- await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
182
- });
227
+ const workflowRuns = presetWorkflowRuns !== undefined
228
+ ? presetWorkflowRuns
229
+ : drainWorkflowRuns({ terminal: Boolean(terminalError) });
230
+ // Entries are { id, attemptId } (the relay fences each mark on its
231
+ // attempt) or bare ids.
232
+ const consumed = (Array.isArray(consumedSteerIds) ? consumedSteerIds : [])
233
+ .map((entry) => (entry && typeof entry === 'object'
234
+ ? { id: String(entry.id || '').trim(), attemptId: entry.attemptId || null }
235
+ : { id: String(entry || '').trim(), attemptId: null }))
236
+ .filter((entry) => entry.id);
237
+ try {
238
+ await api('POST', '/api/response', {
239
+ messageId: message.id,
240
+ conversationId: message.conversationId,
241
+ text: String(text || ''),
242
+ model: model || null,
243
+ modelOrigin: modelOrigin
244
+ || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
245
+ ...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
246
+ ...(terminalError ? { terminalError } : {}),
247
+ // The relay stamps the assistant message's kind from these: 'absorbed'
248
+ // renders as merged into the next (steered) user message, 'folded' as
249
+ // a compact marker on its own message, 'stopped' as a steer the
250
+ // user's Stop cut off unanswered.
251
+ ...(responseKind === 'absorbed' ? { absorbed: true } : {}),
252
+ ...(responseKind && responseKind !== 'absorbed' ? { kind: responseKind } : {}),
253
+ ...(consumed.length ? { consumedSteerIds: consumed } : {}),
254
+ ...attemptFields(message),
255
+ });
256
+ return 'published';
257
+ } catch (error) {
258
+ // A stale_attempt 409 means the row already moved on to another attempt
259
+ // (requeued and re-delivered); the requeue would 409 the same way, and
260
+ // the newer attempt owes the row its answer — drop this one.
261
+ if (isStaleAttemptError(error)) {
262
+ dbg('response refused as stale_attempt; dropping', message.id);
263
+ return 'stale';
264
+ }
265
+ if (responseKind || !requeueOnFailure) {
266
+ dbg('response publish failed; not requeuing a consumed prompt', message.id, error?.message || String(error));
267
+ return 'failed';
268
+ }
269
+ await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
270
+ return 'requeued';
271
+ }
183
272
  }
184
273
 
185
274
  /**
@@ -214,7 +303,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
214
303
  * plan-mode fallback board, response row. Mirrors the pre-session-process
215
304
  * turn runner's tail exactly.
216
305
  */
217
- async function publishCompletedTurn({ message, state, responseModel, planBoardPosted }) {
306
+ async function publishCompletedTurn({ message, state, responseModel, planBoardPosted, consumedSteerIds = null }) {
218
307
  const result = state.result;
219
308
  const finalText = String(
220
309
  state.resultTexts.join('\n\n') || result?.text || state.lastStreamedText || '',
@@ -236,10 +325,10 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
236
325
  // fails the message with a misleading "Relay timeout".
237
326
  const publishedText = finalText || EMPTY_TURN_COMPLETION_NOTE;
238
327
  await publishFinalStream(message, publishedText);
239
- await publishResponse(message, { text: publishedText, model: responseModel });
328
+ await publishResponse(message, { text: publishedText, model: responseModel, consumedSteerIds });
240
329
  }
241
330
 
242
- async function publishErrorResult({ message, state, responseModel }) {
331
+ async function publishErrorResult({ message, state, responseModel, consumedSteerIds = null }) {
243
332
  const result = state.result;
244
333
  const errorText = result.text
245
334
  || `Claude turn failed (${result.subtype || 'unknown error'}).`;
@@ -255,6 +344,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
255
344
  failedAt: new Date().toISOString(),
256
345
  queueMessageId: String(message.id || '') || null,
257
346
  },
347
+ consumedSteerIds,
258
348
  });
259
349
  }
260
350
 
@@ -278,6 +368,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
278
368
 
279
369
  return {
280
370
  postActivity,
371
+ drainWorkflowRuns,
281
372
  dispatchAction,
282
373
  publishContextUsage,
283
374
  publishPlanUsage,
@@ -0,0 +1,9 @@
1
+ // The Claude worker's glue between its session runner and the relay socket
2
+ // link. The implementation moved to shared/worker-runtime/runner-link-wiring.mjs
3
+ // when the Copilot SDK worker adopted the same delivery gate; this module is
4
+ // the import path the Claude worker and its tests still use.
5
+
6
+ export {
7
+ createRunnerLinkBridge,
8
+ failSettlingRowsOnShutdown,
9
+ } from '../../shared/worker-runtime/runner-link-wiring.mjs';
@@ -91,7 +91,11 @@ export function buildInstalledCopilotClientOptions({
91
91
  path: cliPath,
92
92
  },
93
93
  mode: 'empty',
94
- baseDirectory,
94
+ // Runtime 1.0.83 made 'empty' mode refuse to construct without an explicit
95
+ // per-session persistence location. ~/.copilot is exactly what the runtime
96
+ // defaulted to before (baseDirectory sets COPILOT_HOME), so the import
97
+ // sweep keeps seeing the CLI's real session state.
98
+ baseDirectory: String(baseDirectory || '').trim() || path.join(os.homedir(), '.copilot'),
95
99
  useLoggedInUser: true,
96
100
  logLevel,
97
101
  workingDirectory: cwd,
@@ -119,8 +123,13 @@ export async function createInstalledCopilotClient({
119
123
  approveAll: sdk.approveAll,
120
124
  paths,
121
125
  async dispose() {
122
- await client.stop?.();
123
- await client.dispose?.();
126
+ // stop() can reject (e.g. the CLI already died); dispose() must still
127
+ // run or the transport resources leak with it.
128
+ try {
129
+ await client.stop?.();
130
+ } finally {
131
+ await client.dispose?.();
132
+ }
124
133
  },
125
134
  };
126
135
  }
@@ -0,0 +1,89 @@
1
+ // The relay's own `ask_user` tool for Copilot SDK sessions.
2
+ //
3
+ // The runtime's built-in `ask_user` offers the model only `question` and
4
+ // `choices` (captured from the request body, runtime 1.0.88), so a model has
5
+ // no way to say "the user may pick several of these" — and the relay could
6
+ // only guess from the wording. The SDK lets a host register a tool that
7
+ // overrides a built-in one of the same name (`overridesBuiltInTool`), so the
8
+ // worker registers this superset instead: the same name and fields every
9
+ // model already knows, plus `multi_select`. Verified with the fake-provider
10
+ // probe (2026-09-25): the model sees exactly one `ask_user` with this schema,
11
+ // the handler receives the call, and the built-in path is never used.
12
+ //
13
+ // Pure: schema, argument parsing and the result wording. The session process
14
+ // wires the handler to the relay question bridge.
15
+
16
+ import { normalizeUserInputChoices } from './copilot-question-bridge.mjs';
17
+
18
+ export const RELAY_ASK_USER_TOOL_NAME = 'ask_user';
19
+
20
+ export const RELAY_ASK_USER_TOOL_DESCRIPTION = [
21
+ 'Ask the user a question and wait for their response.',
22
+ 'Use this tool when you need to ask the user questions during execution. This allows you to:',
23
+ '1. Gather user preferences or requirements',
24
+ '2. Clarify ambiguous instructions',
25
+ '3. Get decisions on implementation choices as you work',
26
+ '4. Offer choices to the user about what direction to take',
27
+ 'The question is shown to the user as a card with a button per choice.',
28
+ 'Set multi_select to true when the user may pick several of the choices (the choices are not',
29
+ 'mutually exclusive); the answer then lists every pick, separated by ", ". Leave it false or',
30
+ 'omit it when exactly one choice applies.',
31
+ ].join('\n');
32
+
33
+ export const RELAY_ASK_USER_TOOL_PARAMETERS = Object.freeze({
34
+ type: 'object',
35
+ properties: {
36
+ question: {
37
+ type: 'string',
38
+ description: 'The question to ask the user. Ensure only one question is asked at a time - do not bundle multiple questions together.',
39
+ },
40
+ choices: {
41
+ type: 'array',
42
+ items: { type: 'string' },
43
+ description: 'Optional list of choices for a multiple choice question. Prefer providing choices when possible.',
44
+ },
45
+ multi_select: {
46
+ type: 'boolean',
47
+ description: 'true when the user may pick several of the choices; false or omitted when exactly one applies.',
48
+ },
49
+ },
50
+ required: ['question'],
51
+ });
52
+
53
+ /** The call's arguments, tolerating the camelCase spelling some models emit. */
54
+ export function parseAskUserToolArguments(args) {
55
+ const source = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
56
+ return {
57
+ question: String(source.question || '').trim(),
58
+ choices: normalizeUserInputChoices(source.choices),
59
+ multiSelect: source.multi_select === true || source.multiSelect === true,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * The tool result the model reads, worded like the built-in's ("User
65
+ * selected: b" for a choice, "User responded: …" for anything else) so a
66
+ * model's habits carry over. A timed-out card returns the bridge's
67
+ * continuation text as it is.
68
+ */
69
+ export function formatAskUserToolResult({ answer, wasFreeform = true, timedOut = false } = {}) {
70
+ const text = String(answer ?? '').trim();
71
+ if (timedOut) return text;
72
+ if (!text) return 'User responded with an empty answer.';
73
+ // `wasFreeform` comes from the question bridge (`deriveWasFreeform`), the
74
+ // single owner of "was this one of the offered choices".
75
+ return wasFreeform === false ? `User selected: ${text}` : `User responded: ${text}`;
76
+ }
77
+
78
+ /**
79
+ * Whether a session create/resume failure is the runtime refusing the
80
+ * override (an older runtime without `overridesBuiltInTool`, or one that
81
+ * rejects replacing this built-in). Anything else is a real failure.
82
+ */
83
+ export function isToolOverrideRejection(error) {
84
+ const message = String(error?.message || error || '');
85
+ if (/overridesBuiltInTool/i.test(message)) return true;
86
+ // The tool named together with an override/clash complaint — a message that
87
+ // merely mentions ask_user (a replayed call in history, say) is not one.
88
+ return /\bask_user\b/i.test(message) && /(built-?in|clash|conflict|overrid|duplicate)/i.test(message);
89
+ }
@@ -9,7 +9,24 @@ function isImageAttachment(att) {
9
9
  return String(att?.type || '').toLowerCase().startsWith('image/');
10
10
  }
11
11
 
12
- function imageFromDataUrl(att) {
12
+ /** Strict base64: the alphabet, optional padding, block length a multiple of 4. */
13
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
14
+
15
+ /**
16
+ * Decode-and-validate an inline data URL (audit #33).
17
+ *
18
+ * Disk-backed images have always enforced the decoded 5 MiB ceiling; data URLs
19
+ * used to skip it entirely, so a ~9 MiB decoded image (the server's ENCODED
20
+ * ceiling is larger) could ride straight past a limit this module claims to
21
+ * enforce. The ceiling is now applied to the DECODED byte count, uniformly with
22
+ * the disk path, and the base64 itself is validated first — `Buffer.from` is
23
+ * lenient and would silently decode garbage into a corrupt blob.
24
+ *
25
+ * Returns `{ data, mimeType }` for an embeddable image, `{ tooLarge: true }`
26
+ * for a valid image over the ceiling (so the caller can degrade explicitly),
27
+ * and `null` for anything invalid.
28
+ */
29
+ function imageFromDataUrl(att, maxBytes) {
13
30
  const dataUrl = String(att?.dataUrl || '').trim();
14
31
  if (!dataUrl.startsWith('data:')) return null;
15
32
  const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/i);
@@ -17,6 +34,8 @@ function imageFromDataUrl(att) {
17
34
  const mimeType = String(match[1] || '').trim().toLowerCase();
18
35
  const data = String(match[2] || '').trim();
19
36
  if (!mimeType.startsWith('image/') || !data) return null;
37
+ if (data.length % 4 !== 0 || !BASE64_RE.test(data)) return null;
38
+ if (Buffer.from(data, 'base64').length > maxBytes) return { tooLarge: true };
20
39
  return { data, mimeType };
21
40
  }
22
41
 
@@ -59,6 +78,7 @@ export function buildCopilotMessageOptions(message, {
59
78
 
60
79
  if (isImageAttachment(att)) {
61
80
  let blob = null;
81
+ let tooLarge = false;
62
82
  if (filePath && fsImpl.existsSync(filePath)) {
63
83
  try {
64
84
  const bytes = fsImpl.readFileSync(filePath);
@@ -72,13 +92,26 @@ export function buildCopilotMessageOptions(message, {
72
92
  blob = null;
73
93
  }
74
94
  }
75
- if (!blob) blob = imageFromDataUrl(att);
95
+ if (!blob) {
96
+ // The decoded ceiling applies to data URLs exactly as to disk reads —
97
+ // this is also the path an OVERSIZED disk image with a dataUrl twin
98
+ // used to sneak through.
99
+ const decoded = imageFromDataUrl(att, maxInlineImageBytes);
100
+ if (decoded?.tooLarge) tooLarge = true;
101
+ else blob = decoded;
102
+ }
76
103
  if (blob) {
77
104
  attachments.push({ type: 'blob', data: blob.data, mimeType: blob.mimeType, displayName: name });
78
105
  noteLines.push(`Attached image "${name}" (${mime}) is embedded in this message.`);
79
106
  } else if (filePath) {
80
107
  attachments.push({ type: 'file', path: filePath, displayName: name });
81
108
  noteLines.push(`Attached image "${name}" (${mime}): ${filePath}`);
109
+ } else if (tooLarge) {
110
+ // A valid image with nowhere to degrade to: too big to embed, no path
111
+ // for a file reference. Dropping it SILENTLY would leave the model
112
+ // answering about an image it never saw, so the omission is stated in
113
+ // the same note channel every other attachment uses.
114
+ noteLines.push(`Attached image "${name}" (${mime}) was too large to embed and has no file path, so it was omitted.`);
82
115
  }
83
116
  continue;
84
117
  }
@@ -54,6 +54,51 @@ export const SHELL_SETTLED_NOTIFICATION_KINDS = Object.freeze([
54
54
  /** Tools that address an already-running shell by id. */
55
55
  export const SHELL_HANDLE_TOOLS = Object.freeze(['read_bash', 'write_bash', 'stop_bash']);
56
56
 
57
+ /**
58
+ * `system.notification` kinds that mean "a background agent this session
59
+ * started is done" — `agent_idle` for a multi-turn agent that finished its
60
+ * prompt and now waits for messages, `agent_completed` for a single-shot one.
61
+ * Both make the runtime re-invoke the model (`read_agent` + a reply under the
62
+ * spawning interaction), so both herald a continuation exactly like the shell
63
+ * kinds do (live-probed on runtime 1.0.88, 2026-09-25).
64
+ */
65
+ export const AGENT_SETTLED_NOTIFICATION_KINDS = Object.freeze([
66
+ 'agent_completed',
67
+ 'agent_idle',
68
+ ]);
69
+
70
+ /**
71
+ * The background agent a `system.notification` reports as settled, or null.
72
+ * `{ agentId, agentType, displayName, description, status }` — `status` is
73
+ * 'completed' | 'failed' for `agent_completed`, 'idle' for `agent_idle`.
74
+ */
75
+ export function settledAgentFromNotification(event) {
76
+ if (String(event?.type || '') !== 'system.notification') return null;
77
+ const data = eventData(event);
78
+ const kind = data.kind && typeof data.kind === 'object' ? data.kind : {};
79
+ const type = String(kind.type || '');
80
+ if (!AGENT_SETTLED_NOTIFICATION_KINDS.includes(type)) return null;
81
+ const agentId = String(kind.agentId || '').trim();
82
+ if (!agentId) return null;
83
+ return {
84
+ agentId,
85
+ agentType: String(kind.agentType || '').trim(),
86
+ displayName: String(kind.displayName || '').trim(),
87
+ description: String(kind.description || '').trim(),
88
+ status: type === 'agent_idle' ? 'idle' : (String(kind.status || '').trim() || 'completed'),
89
+ };
90
+ }
91
+
92
+ /** A one-line transcript note for a settled background agent, posted into its continuation. */
93
+ export function describeSettledAgent(agent) {
94
+ if (!agent?.agentId) return '';
95
+ const name = agent.displayName || agent.description || agent.agentType || 'agent';
96
+ const verb = agent.status === 'failed' ? 'failed' : 'finished';
97
+ return agent.description && agent.description !== name
98
+ ? `Background agent ${name} ${verb}: ${agent.description}`
99
+ : `Background agent ${name} ${verb}`;
100
+ }
101
+
57
102
  // `<command started in detached background with shellId: 1>`
58
103
  const SHELL_OPENED_RE = /detached\s+(?:background|command)[^<>]*?shellId:\s*([^\s>,]+)/i;
59
104
  // `<detached command with shellId: 1 completed with exit code 0>`
@@ -118,7 +163,17 @@ export const CONTINUATION_OPENING_EVENT_TYPES = Object.freeze([
118
163
 
119
164
  const OPENING_TYPES = new Set(CONTINUATION_OPENING_EVENT_TYPES);
120
165
 
166
+ /**
167
+ * Only ROOT-agent events open a turn. A background subagent keeps emitting
168
+ * `agentId`-tagged `tool.execution_start` / `assistant.message` / its own
169
+ * `user.message` after the turn that spawned it has settled (live-probed on
170
+ * runtime 1.0.88: the subagent's whole life happens under the main loop's
171
+ * idle), and none of that is the main agent starting work. The root agent's
172
+ * follow-up when the subagent finishes (`read_agent` + a reply) carries no
173
+ * `agentId` and opens the continuation as before.
174
+ */
121
175
  export function isContinuationOpeningEvent(event) {
176
+ if (String(event?.agentId || '').trim()) return false;
122
177
  return OPENING_TYPES.has(String(event?.type || ''));
123
178
  }
124
179