@oar-sh/oar 0.9.4 → 0.9.6

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 (71) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +199 -0
  4. package/README.md +35 -6
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  7. package/server/claude-worker/claude-session-process.mjs +411 -19
  8. package/server/claude-worker/claude-session-worker.mjs +5 -0
  9. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  10. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  11. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  12. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  13. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  14. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  17. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  19. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  20. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  21. package/server/db-schema.mjs +7 -0
  22. package/server/grok-worker/acp-client.mjs +68 -12
  23. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  24. package/server/grok-worker/grok-session-worker.mjs +5 -0
  25. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  26. package/server/public/app/api-client.js +17 -2
  27. package/server/public/app/bootstrap.js +3 -0
  28. package/server/public/app/composer-control-state.mjs +4 -3
  29. package/server/public/app/conversation-view.js +97 -4
  30. package/server/public/app/journal-view.js +6 -0
  31. package/server/public/app/markdown-strikethrough.mjs +31 -0
  32. package/server/public/app/relay-board-inline.mjs +70 -0
  33. package/server/public/app/relay-board-view.js +157 -61
  34. package/server/public/app/relay-error-ctas.mjs +12 -3
  35. package/server/public/app/socket-handlers.js +9 -0
  36. package/server/public/app/steered-turn-merge.mjs +10 -1
  37. package/server/public/app/store.js +4 -1
  38. package/server/public/app/subagent-fold.mjs +61 -0
  39. package/server/public/app/usage-limit-ui.js +225 -0
  40. package/server/public/index.html +97 -4
  41. package/server/repositories/message-repository.mjs +10 -1
  42. package/server/repositories/question-repository.mjs +1 -0
  43. package/server/repositories/remote-relay-repository.mjs +30 -0
  44. package/server/routes/messages-routes.mjs +93 -17
  45. package/server/routes/relay-board-routes.mjs +56 -2
  46. package/server/routes/sessions-routes.mjs +40 -2
  47. package/server/server-runtime.mjs +33 -2
  48. package/server/services/preview-proxy-server.mjs +64 -36
  49. package/server/services/process-list-service.mjs +122 -0
  50. package/server/services/relay-console-log.mjs +199 -0
  51. package/server/services/remote-relay-caller-context.mjs +45 -20
  52. package/server/services/remote-relay-dispatcher.mjs +328 -21
  53. package/server/services/remote-relay-inbound.mjs +35 -0
  54. package/server/services/session-worker-launch-service.mjs +17 -2
  55. package/server/services/session-worker-process-service.mjs +85 -32
  56. package/server/services/session-worker-registry-service.mjs +1 -1
  57. package/server/services/session-worker-supervisor-service.mjs +33 -1
  58. package/server/services/usage-limit-pause-service.mjs +441 -0
  59. package/shared/ask-user-bridge.mjs +14 -39
  60. package/shared/claude-usage-limit.mjs +257 -0
  61. package/shared/control-poller.mjs +69 -20
  62. package/shared/failure-note-text.mjs +38 -0
  63. package/shared/question-wait.mjs +108 -0
  64. package/shared/remote-relay-contract.mjs +33 -2
  65. package/shared/steer-settle-failure.mjs +22 -0
  66. package/shared/steer-settle-markers.mjs +24 -0
  67. package/shared/worker-runtime/process-tree.mjs +483 -0
  68. package/shared/worker-runtime/relay-errors.mjs +31 -0
  69. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  70. package/shared/worker-runtime/turn-liveness.mjs +249 -0
  71. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -0,0 +1,92 @@
1
+ // Why a Claude turn failed: the code the relay prints, and the advice that
2
+ // fits the failure.
3
+ //
4
+ // A turn the API refused ends with a `result` of subtype 'success' and
5
+ // `is_error: true`: the subtype says how the turn ended, not whether it
6
+ // worked, so it names nothing. What names the failure, in this order:
7
+ //
8
+ // 1. the `error` field of the turn's last assistant message (the SDK's
9
+ // SDKAssistantMessageError: 'billing_error', 'oauth_org_not_allowed', …);
10
+ // 2. the `api_error_status` of the result;
11
+ // 3. the words of the refusal, for an account failure that came with
12
+ // neither;
13
+ // 4. a subtype other than 'success' ('error_max_turns', …);
14
+ // 5. the `terminal_reason` of the result.
15
+ //
16
+ // The relay prints `claude.<code>` as `relay.claude-<code>`, dashes for
17
+ // underscores.
18
+
19
+ // The user has to sign in again: a retry cannot succeed before that.
20
+ const SIGN_IN_ERRORS = new Set(['authentication_failed']);
21
+ // The account itself refuses: billing, or access its organisation withholds.
22
+ const ACCOUNT_ERRORS = new Set([
23
+ 'billing_error',
24
+ 'oauth_org_not_allowed',
25
+ 'account_on_hold',
26
+ 'verification_required',
27
+ 'account-access',
28
+ ]);
29
+
30
+ const STATUS_CODES = new Map([
31
+ [401, 'authentication_failed'],
32
+ [402, 'billing_error'],
33
+ [429, 'rate_limit'],
34
+ [529, 'overloaded'],
35
+ ]);
36
+
37
+ // Checked before the sign-in words: the organisation's refusal mentions an
38
+ // API key too.
39
+ const ACCOUNT_ACCESS_TEXT = /organi[sz]ation has disabled|subscription access|does not have access to claude/i;
40
+ const BILLING_TEXT = /credit balance|billing|payment/i;
41
+ const SIGN_IN_TEXT = /failed to authenticate|authentication_error|invalid api key|not logged in|please run \/login/i;
42
+
43
+ export const CLAUDE_SIGN_IN_GUIDANCE =
44
+ 'Sign in to Claude again in Settings → Providers → Claude → Relogin (or run `claude` on the relay host), '
45
+ + 'then send the message again.';
46
+ export const CLAUDE_ACCOUNT_GUIDANCE =
47
+ 'Check the Claude account the relay host is signed in to: its subscription, its payment method, and whether '
48
+ + 'its organisation allows Claude Code. Settings → Providers → Claude shows the account and signs in to '
49
+ + 'another one. Then send the message again.';
50
+ export const CLAUDE_MODEL_GUIDANCE = 'Choose another model for this conversation, then send the message again.';
51
+
52
+ function codeFromText(text) {
53
+ if (ACCOUNT_ACCESS_TEXT.test(text)) return 'account-access';
54
+ if (BILLING_TEXT.test(text)) return 'billing_error';
55
+ if (SIGN_IN_TEXT.test(text)) return 'authentication_failed';
56
+ return null;
57
+ }
58
+
59
+ /** The advice for a code, or null where the relay's own default fits. */
60
+ export function claudeFailureGuidance(code) {
61
+ const token = String(code || '').trim().toLowerCase();
62
+ if (SIGN_IN_ERRORS.has(token)) return CLAUDE_SIGN_IN_GUIDANCE;
63
+ if (ACCOUNT_ERRORS.has(token)) return CLAUDE_ACCOUNT_GUIDANCE;
64
+ if (token === 'model_not_found') return CLAUDE_MODEL_GUIDANCE;
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * `result` is the normalized result payload of the turn (sdk-message-
70
+ * normalizer): `{ text, subtype, assistantError, apiErrorStatus,
71
+ * terminalReason }`. Returns `{ code, stableCode, guidance }`; `guidance` is
72
+ * null where the relay's default fits.
73
+ */
74
+ export function classifyClaudeResultFailure(result = {}) {
75
+ const subtype = String(result?.subtype || '').trim();
76
+ const assistantError = String(result?.assistantError || '').trim().toLowerCase();
77
+ const status = Number(result?.apiErrorStatus);
78
+ const hasStatus = Number.isFinite(status) && status >= 400;
79
+ const terminalReason = String(result?.terminalReason || '').trim().toLowerCase();
80
+
81
+ let code = null;
82
+ if (assistantError && assistantError !== 'unknown') code = assistantError;
83
+ else if (hasStatus && STATUS_CODES.has(status)) code = STATUS_CODES.get(status);
84
+ else code = codeFromText(String(result?.text || ''));
85
+
86
+ if (!code && subtype && subtype !== 'success') code = subtype;
87
+ if (!code && (hasStatus || assistantError === 'unknown' || terminalReason === 'api_error')) code = 'api-error';
88
+ if (!code && terminalReason && terminalReason !== 'completed') code = terminalReason;
89
+ if (!code) code = 'turn-error';
90
+
91
+ return { code, stableCode: `claude.${code}`, guidance: claudeFailureGuidance(code) };
92
+ }
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
+ import { classifyClaudeResultFailure, claudeFailureGuidance } from './claude-turn-failure.mjs';
2
3
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
3
4
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
4
5
  import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
6
+ import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
5
7
 
6
8
  const PLAN_BOARD_ACTIONS = [
7
9
  { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
@@ -178,6 +180,18 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
178
180
  });
179
181
  }
180
182
 
183
+ /**
184
+ * Ship what the CLI reported about the subscription's usage limit: the
185
+ * relay warns before the limit and shows the reset once it is reached.
186
+ * Advisory, and fired without waiting: it must not hold up the stream.
187
+ */
188
+ function publishUsageLimitReport({ conversationId, sdkSessionId, report }) {
189
+ if (!report) return;
190
+ api('POST', '/api/claude-usage-limit', { conversationId, sdkSessionId, report }).catch((error) => {
191
+ dbg('usage limit report publish failed', error?.message || String(error));
192
+ });
193
+ }
194
+
181
195
  async function publishFinalStream(message, text) {
182
196
  await api('POST', '/api/stream', {
183
197
  messageId: message.id,
@@ -204,6 +218,9 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
204
218
  text,
205
219
  model,
206
220
  terminalError = null,
221
+ // What the turn had written before it failed; the relay keeps it above
222
+ // the failure or pause note.
223
+ partialText = '',
207
224
  modelOrigin,
208
225
  absorbed = false,
209
226
  kind = null,
@@ -244,6 +261,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
244
261
  || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
245
262
  ...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
246
263
  ...(terminalError ? { terminalError } : {}),
264
+ ...(terminalError && String(partialText || '').trim() ? { partialText: String(partialText).trim() } : {}),
247
265
  // The relay stamps the assistant message's kind from these: 'absorbed'
248
266
  // renders as merged into the next (steered) user message, 'folded' as
249
267
  // a compact marker on its own message, 'stopped' as a steer the
@@ -333,13 +351,34 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
333
351
  const errorText = result.text
334
352
  || `Claude turn failed (${result.subtype || 'unknown error'}).`;
335
353
  await publishFinalStream(message, state.lastStreamedText);
354
+ // Refused at the usage limit: a relay that knows the kind pauses the turn
355
+ // until the reset instead of failing it.
356
+ if (result.usageLimit) {
357
+ // What the turn had written before it was refused, without the CLI's
358
+ // own line about the limit, which is the last thing in the stream. The
359
+ // relay keeps it above its note: a turn cut off in the middle has
360
+ // usually said what it was doing.
361
+ const refusalText = String(result.usageLimit.text || '').trim();
362
+ let written = String(state.lastStreamedText || '').trim();
363
+ if (refusalText && written.endsWith(refusalText)) written = written.slice(0, -refusalText.length).trim();
364
+ await publishResponse(message, {
365
+ text: errorText,
366
+ model: responseModel,
367
+ terminalError: buildUsageLimitFailure(message, result.usageLimit),
368
+ partialText: written,
369
+ consumedSteerIds,
370
+ });
371
+ return;
372
+ }
336
373
  await publishResponse(message, {
337
374
  text: errorText,
338
375
  model: responseModel,
339
376
  terminalError: {
340
377
  kind: 'claude-turn-failed',
341
- code: result.subtype || 'unknown',
342
- stableCode: `claude.${result.subtype || 'unknown'}`,
378
+ // Never built from the subtype alone: a refused turn ends with
379
+ // subtype 'success'. An account failure brings its own advice, since
380
+ // sending the message again cannot help before the account is fixed.
381
+ ...classifyClaudeResultFailure(result),
343
382
  message: errorText,
344
383
  failedAt: new Date().toISOString(),
345
384
  queueMessageId: String(message.id || '') || null,
@@ -348,7 +387,15 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
348
387
  });
349
388
  }
350
389
 
351
- async function publishTurnException({ message, errorText }) {
390
+ async function publishTurnException({ message, errorText, usageLimit = null }) {
391
+ if (usageLimit) {
392
+ await publishResponse(message, {
393
+ text: usageLimit.text || errorText,
394
+ model: null,
395
+ terminalError: buildUsageLimitFailure(message, usageLimit),
396
+ });
397
+ return;
398
+ }
352
399
  const isAuthError = /authentication|logged in|login|credential|api key/i.test(errorText);
353
400
  await publishResponse(message, {
354
401
  text: isAuthError
@@ -360,6 +407,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
360
407
  code: isAuthError ? 'authentication_failed' : 'turn-error',
361
408
  stableCode: isAuthError ? 'claude.authentication_failed' : 'claude.turn-error',
362
409
  message: errorText,
410
+ ...(isAuthError ? { guidance: claudeFailureGuidance('authentication_failed') } : {}),
363
411
  failedAt: new Date().toISOString(),
364
412
  queueMessageId: String(message.id || '') || null,
365
413
  },
@@ -372,6 +420,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
372
420
  dispatchAction,
373
421
  publishContextUsage,
374
422
  publishPlanUsage,
423
+ publishUsageLimitReport,
375
424
  publishFinalStream,
376
425
  publishResponse,
377
426
  publishPlanBoard,
@@ -142,13 +142,17 @@ export { shouldEmitStreamUpdate };
142
142
  * - `background_tasks` → `{ tasks: [{ taskId, taskType, description }] }`
143
143
  * (REPLACE semantics: the full live set after each membership change)
144
144
  * - `background_task_settled` → `{ taskId, status }`
145
- * - `result` → `{ text, isError, subtype, sessionId, model, usage }`
145
+ * - `result` → `{ text, isError, subtype, sessionId, model, usage }`, and what
146
+ * names a failure: `{ assistantError, apiErrorStatus, terminalReason }`
146
147
  */
147
148
  export function createSdkMessageNormalizer() {
148
149
  const threads = new Map(); // threadKey -> per-thread stream/thinking bookkeeping
149
150
  const knownSubagentRuns = new Map(); // toolUseId -> { displayName, parentSubagentId }
150
151
  let initSessionId = '';
151
152
  let initModel = '';
153
+ // The `error` of the turn's last main-thread assistant message: the SDK's
154
+ // name for the failure a refused turn ended on.
155
+ let lastAssistantError = '';
152
156
 
153
157
  function threadKeyFor(parentToolUseId) {
154
158
  return String(parentToolUseId || '').trim() || 'main';
@@ -532,6 +536,7 @@ export function createSdkMessageNormalizer() {
532
536
  const parentToolUseId = sdkMessage?.parent_tool_use_id || null;
533
537
  const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
534
538
  const messageId = String(sdkMessage?.message?.id || '').trim();
539
+ if (!parentToolUseId) lastAssistantError = String(sdkMessage?.error || '').trim();
535
540
  return actionsForAssistantMessage(content, parentToolUseId, messageId);
536
541
  }
537
542
 
@@ -599,12 +604,19 @@ export function createSdkMessageNormalizer() {
599
604
  && Number(sdkMessage.duration_api_ms) === 0;
600
605
  if (isPhantom) return [];
601
606
  const isError = sdkMessage.subtype !== 'success' || sdkMessage.is_error === true;
607
+ // The next turn's result must not inherit this turn's failure.
608
+ const assistantError = lastAssistantError;
609
+ lastAssistantError = '';
610
+ const apiErrorStatus = Number(sdkMessage.api_error_status ?? NaN);
602
611
  return [{
603
612
  channel: 'result',
604
613
  payload: {
605
614
  text: String(sdkMessage.result || '').trim(),
606
615
  isError,
607
616
  subtype: String(sdkMessage.subtype || ''),
617
+ assistantError,
618
+ apiErrorStatus: Number.isFinite(apiErrorStatus) ? apiErrorStatus : null,
619
+ terminalReason: String(sdkMessage.terminal_reason || ''),
608
620
  sessionId: String(sdkMessage.session_id || initSessionId || '').trim(),
609
621
  model: initModel,
610
622
  usage: sdkMessage.usage || null,
@@ -112,6 +112,22 @@ export function createModelSwitchUnconfirmedError({
112
112
  return error;
113
113
  }
114
114
 
115
+ // JSON-RPC reserved codes the SDK's transport uses for a connection that went
116
+ // away (`MessageWriteError` … `ConnectionInactive`), as opposed to a server
117
+ // handler that threw.
118
+ const TRANSPORT_ERROR_CODES = new Set([-32099, -32098, -32097, -32096]);
119
+
120
+ /**
121
+ * The request failed because the connection to the runtime is gone, not
122
+ * because the runtime refused it.
123
+ */
124
+ export function isRuntimeTransportError(error) {
125
+ const code = Number(error?.code);
126
+ if (Number.isFinite(code) && TRANSPORT_ERROR_CODES.has(code)) return true;
127
+ if (error?.name === 'ConnectionError') return true;
128
+ return /\bconnection (?:got |is |was )?(?:disposed|closed)\b/i.test(String(error?.message || ''));
129
+ }
130
+
115
131
  export function isModelSwitchUnconfirmedError(error) {
116
132
  return error?.modelSwitchUnconfirmed === true;
117
133
  }
@@ -272,6 +288,16 @@ export function createCopilotModelSwitcher({
272
288
  });
273
289
  };
274
290
 
291
+ // A request that failed because the connection to the runtime is gone says
292
+ // nothing about the model: "pick an available model and resend it" sent
293
+ // the user to the model picker over a dead runtime, and because an
294
+ // unconfirmed switch keeps the runtime, every retry failed the same way.
295
+ // Thrown as it is, it fails the turn and the runtime is rebuilt.
296
+ const failOrRethrow = (error, requestedEffort = targetEffortRaw) => {
297
+ if (isRuntimeTransportError(error)) throw error;
298
+ return fail(error?.message || String(error), requestedEffort);
299
+ };
300
+
275
301
  // ---- resolve the effort actually sent on the wire -----------------------
276
302
  // `null` target = "the model's default": mapped to the catalog's
277
303
  // `defaultReasoningEffort` when known, otherwise omitted entirely (and the
@@ -347,7 +373,7 @@ export function createCopilotModelSwitcher({
347
373
  // the only signal left. Hosted trusts it; BYOK falls back to rebuild.
348
374
  if (byok || typeof session?.setModel !== 'function') return fail('the runtime exposes no model-switch RPC');
349
375
  await session.setModel(targetModel, effortToSend ? { reasoningEffort: effortToSend } : undefined)
350
- .catch((error) => fail(error?.message || String(error)));
376
+ .catch((error) => failOrRethrow(error));
351
377
  applied = { model: targetModel, effort: trackedEffort };
352
378
  return { ok: true, changed: true };
353
379
  }
@@ -363,7 +389,7 @@ export function createCopilotModelSwitcher({
363
389
  });
364
390
  } catch (error) {
365
391
  drain.cancel();
366
- return fail(error?.message || String(error));
392
+ return failOrRethrow(error);
367
393
  }
368
394
  if (result?.confirmation) {
369
395
  // A compaction preflight wants an interactive decision; a headless
@@ -404,7 +430,7 @@ export function createCopilotModelSwitcher({
404
430
  try {
405
431
  effortResult = await setEffort.call(session.rpc.model, { reasoningEffort: effortToSend });
406
432
  } catch (error) {
407
- return fail(error?.message || String(error), effortToSend);
433
+ return failOrRethrow(error, effortToSend);
408
434
  }
409
435
  const recorded = String(effortResult?.reasoningEffort || '').trim().toLowerCase();
410
436
  if (recorded && recorded !== effortToSend) {
@@ -23,6 +23,7 @@
23
23
  // question array. Composing the waiter instead of copying it keeps one
24
24
  // implementation of the abort semantics.
25
25
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
26
+ import { createRelayQuestion } from '../../shared/question-wait.mjs';
26
27
  import { looksLikeMultiSelectQuestion } from '../../shared/question-multi-select.mjs';
27
28
  import {
28
29
  DEFAULT_QUESTION_TIMEOUT_MS,
@@ -139,10 +140,7 @@ export function createCopilotQuestionBridge({
139
140
  ...extra,
140
141
  },
141
142
  };
142
- const created = await api('POST', '/api/relay-question', payload);
143
- const questionId = created?.question?.id;
144
- if (!questionId) throw new Error('Relay question could not be created');
145
- return questionId;
143
+ return createRelayQuestion({ api, payload, dbg });
146
144
  }
147
145
 
148
146
  async function ask(spec, { signal, message } = {}) {
@@ -0,0 +1,91 @@
1
+ // The commands of this worker's Copilot runtime, and when they are stopped.
2
+ //
3
+ // The runtime ends its own commands when it ends in good order. When it is
4
+ // killed, or dies, they run on with nobody left to read their result (the
5
+ // measurements are in shared/worker-runtime/process-tree.mjs). The worker
6
+ // therefore keeps the runtime's process tree while the runtime lives and
7
+ // stops what is left of it after a runtime that failed is gone.
8
+ import { createProcessTreeWatch } from '../../shared/worker-runtime/process-tree.mjs';
9
+ import { createProcessLister } from '../services/process-list-service.mjs';
10
+ import { readRuntimePid } from './copilot-sdk-adapter.mjs';
11
+
12
+ /**
13
+ * The reasons of `stopRuntime` after which the tree is stopped: the runtime
14
+ * exited, it no longer answers, or it is torn down because its turn failed.
15
+ * An idle runtime and a worker that shuts down stop in good order, and the
16
+ * runtime ends its commands itself.
17
+ */
18
+ const TREE_STOP_REASONS = new Set(['runtime-exit', 'runtime-gone', 'turn-failure']);
19
+
20
+ export function runtimeStopLeavesCommands(reason) {
21
+ return TREE_STOP_REASONS.has(String(reason || ''));
22
+ }
23
+
24
+ /**
25
+ * The wrapper the runtime puts around a command that was started with
26
+ * `detach: true`. Such a command is meant to outlive the session ("only set
27
+ * this when the user explicitly requires the process to survive"), and the
28
+ * runtime leaves it running when it stops in good order. So does the worker.
29
+ *
30
+ * On Linux and macOS (runtime 1.0.89) the wrapper is a shell that keeps the
31
+ * pid and exit paths in variables. On Windows (1.0.88, seen live) it is a
32
+ * PowerShell that starts the command with Start-Process; the command itself
33
+ * is a child of the wrapper and is kept with it. The wrapper's text is the
34
+ * mark: the names of its files (`copilot-detached-…`) would also match a
35
+ * command that merely mentions one.
36
+ */
37
+ const DETACHED_COMMAND_MARKERS = Object.freeze([
38
+ '__copilot_pid_path=',
39
+ '$__copilotProcess = Start-Process',
40
+ ]);
41
+
42
+ export function isDetachedRuntimeCommand(proc) {
43
+ const commandLine = String(proc?.commandLine || '');
44
+ return DETACHED_COMMAND_MARKERS.some((marker) => commandLine.includes(marker));
45
+ }
46
+
47
+ /**
48
+ * The sentence of the failure note. "is being stopped", not "was": the note
49
+ * is published before the runtime is torn down, and the commands are stopped
50
+ * after it.
51
+ */
52
+ export function describeCommandsBeingStopped(count) {
53
+ const commands = Number(count) || 0;
54
+ if (commands <= 0) return '';
55
+ if (commands === 1) return 'A command that was still running is being stopped.';
56
+ return `${commands} commands that were still running are being stopped.`;
57
+ }
58
+
59
+ /**
60
+ * Watch the process tree of `client`'s runtime. Returns null where the pid of
61
+ * the runtime is not known: nothing is signalled on a guess.
62
+ */
63
+ export function watchCopilotRuntimeTree({
64
+ client,
65
+ isBusy = () => true,
66
+ dbg = () => {},
67
+ platform = process.platform,
68
+ lister = null,
69
+ signal = (pid, name) => process.kill(pid, name),
70
+ // Never signalled, whatever the process list says.
71
+ selfPid = process.pid,
72
+ parentPid = process.ppid,
73
+ ...timing
74
+ } = {}) {
75
+ const rootPid = readRuntimePid(client);
76
+ if (!rootPid) {
77
+ dbg('the pid of the copilot runtime is not known; its commands are not watched');
78
+ return null;
79
+ }
80
+ return createProcessTreeWatch({
81
+ rootPid,
82
+ lister: lister || createProcessLister({ platform }),
83
+ signal,
84
+ protectedPids: [selfPid, parentPid],
85
+ keepSubtree: isDetachedRuntimeCommand,
86
+ isBusy,
87
+ label: 'copilot runtime',
88
+ dbg,
89
+ ...timing,
90
+ });
91
+ }
@@ -28,8 +28,10 @@ import {
28
28
  MODEL_SWITCH_UNCONFIRMED_CODE,
29
29
  MODEL_SWITCH_UNCONFIRMED_STABLE_CODE,
30
30
  isModelSwitchUnconfirmedError,
31
+ isRuntimeTransportError,
31
32
  } from './copilot-model-switch.mjs';
32
33
  import { copilotRuntimeEntry } from '../copilot-sdk-runtime.mjs';
34
+ import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
33
35
 
34
36
  const VERSION_DIR_RE = /^\d+\.\d+\.\d+$/;
35
37
 
@@ -162,6 +164,22 @@ export function observeRuntimeExit(client, onExit) {
162
164
  return () => { live = false; };
163
165
  }
164
166
 
167
+ /**
168
+ * The pid of the runtime process, or 0.
169
+ *
170
+ * Like `processExitPromise` this reaches past the public API: the SDK starts
171
+ * the runtime itself (`spawn` without `detached`, no option for a spawn
172
+ * function of our own, verified against 1.0.89 and the npm package 1.0.14) and
173
+ * keeps the child in `cliProcess`, a TS-private, runtime-visible field that
174
+ * `stop()` and `forceStop()` clear. It is read once, right after the start. A
175
+ * bundle without the field reads as "not known", and the runtime's commands
176
+ * are then not watched.
177
+ */
178
+ export function readRuntimePid(client) {
179
+ const pid = Number(client?.cliProcess?.pid);
180
+ return Number.isInteger(pid) && pid > 1 ? pid : 0;
181
+ }
182
+
165
183
  /**
166
184
  * Import the installed SDK and start a client against the matching runtime.
167
185
  *
@@ -402,6 +420,8 @@ export function classifyCopilotSessionError(data) {
402
420
  code,
403
421
  stableCode: `copilot.${code}`,
404
422
  text: `System note: the Copilot turn failed (${detail}). Retry or send a new message.`,
423
+ // Without one the relay adds its default, which advises a retry.
424
+ guidance: COPILOT_TURN_FAILURE_GUIDANCE,
405
425
  detail,
406
426
  quota: false,
407
427
  };
@@ -469,6 +489,20 @@ export function isCopilotAuthError(error) {
469
489
  return AUTH_ERROR_RE.test(String(error?.message || error || ''));
470
490
  }
471
491
 
492
+ /**
493
+ * What the relay appends to a failed Copilot turn in place of its own default
494
+ * (which advises a retry). The worker deals with its runtime itself: a delivered
495
+ * turn that failed tore it down and the next message starts a fresh one, so a
496
+ * relay restart repairs nothing here and costs every other running session.
497
+ * That holds for a failure the runtime reports as a session error too.
498
+ * Sign-in and quota failures keep the wording of their own.
499
+ */
500
+ export const COPILOT_TURN_FAILURE_GUIDANCE =
501
+ 'If this keeps failing, include the error code when you report it; the relay does not need a restart.';
502
+ export const COPILOT_TURN_RETRY_GUIDANCE = 'Send the message again to retry.';
503
+ export const COPILOT_TURN_CONTINUE_GUIDANCE =
504
+ 'Send a message to continue: the session keeps the work so far, so the agent carries on from there.';
505
+
472
506
  /**
473
507
  * Classify a thrown error (client start, session create/resume, `send`) rather
474
508
  * than a `session.error` event. Auth gets its own branch because the fix is a
@@ -503,19 +537,27 @@ export function classifyCopilotTurnException(error) {
503
537
  if (isCopilotAuthError(error)) {
504
538
  return copilotAuthClassification(detail);
505
539
  }
540
+ // The stall watchdog's failure: the turn is not known to be broken, only
541
+ // silent, and the user is told that — and what the runtime was doing —
542
+ // rather than "the turn failed".
543
+ if (isTurnStalledError(error)) {
544
+ return {
545
+ code: 'turn-stalled',
546
+ stableCode: 'copilot.turn-stalled',
547
+ text: describeTurnStall(error),
548
+ guidance: COPILOT_TURN_RETRY_GUIDANCE,
549
+ detail,
550
+ };
551
+ }
506
552
  return {
507
553
  code: 'turn-error',
508
554
  stableCode: 'copilot.turn-error',
509
555
  text: `System note: the Copilot turn failed (${detail}). Retry or send a new message.`,
556
+ guidance: COPILOT_TURN_FAILURE_GUIDANCE,
510
557
  detail,
511
558
  };
512
559
  }
513
560
 
514
- // JSON-RPC reserved codes the SDK's transport uses for a connection that went
515
- // away (`MessageWriteError` … `ConnectionInactive`), as opposed to a server
516
- // handler that threw.
517
- const TRANSPORT_ERROR_CODES = new Set([-32099, -32098, -32097, -32096]);
518
-
519
561
  /**
520
562
  * Did `resumeSession` fail because the session does not exist, or because the
521
563
  * call itself failed?
@@ -530,9 +572,7 @@ const TRANSPORT_ERROR_CODES = new Set([-32099, -32098, -32097, -32096]);
530
572
  * conversation history.
531
573
  */
532
574
  export function isSessionNotFoundError(error) {
533
- const code = Number(error?.code);
534
- if (Number.isFinite(code) && TRANSPORT_ERROR_CODES.has(code)) return false;
535
- if (error?.name === 'ConnectionError') return false;
575
+ if (isRuntimeTransportError(error)) return false;
536
576
  const detail = String(error?.message || error || '').toLowerCase();
537
577
  if (!detail) return false;
538
578
  if (/\b(?:session|conversation)\b[^.]{0,40}\bnot found\b/.test(detail)) return true;