@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
@@ -0,0 +1,160 @@
1
+ import {
2
+ applyPreviewInstructions,
3
+ createPreviewInstructionsProvider,
4
+ } from "../preview-instructions.mjs";
5
+
6
+ export { createPreviewInstructionsProvider };
7
+
8
+ export function buildPrompt(message) {
9
+ const text = String(message?.text || "").trim();
10
+ const attachments = Array.isArray(message?.attachments) ? message.attachments : [];
11
+ if (!attachments.length) return text;
12
+
13
+ const attachmentNote = attachments.map((att) => {
14
+ const name = att?.name ? String(att.name) : "attachment";
15
+ const type = att?.type ? String(att.type) : "file";
16
+ if (type.startsWith("image/")) return `${name} (${type}, inline image attachment)`;
17
+ const reference = att?.path ? ` @${att.path}` : "";
18
+ return `${name} (${type})${reference}`;
19
+ }).join(", ");
20
+ const suffix = `\n\n[Attached file${attachments.length === 1 ? "" : "s"}: ${attachmentNote}]`;
21
+ return text ? `${text}${suffix}` : suffix.trimStart();
22
+ }
23
+
24
+ function normalizeRelayMode(mode) {
25
+ const value = String(mode || "agent").trim().toLowerCase();
26
+ if (value === "plan" || value === "ask" || value === "autopilot" || value === "agent") return value;
27
+ return "agent";
28
+ }
29
+
30
+ function buildModeInstructionText(mode) {
31
+ if (mode === "plan") {
32
+ return [
33
+ "Draft a concise plan only.",
34
+ "Use read-only inspection tools (glob, rg, view) only when they materially improve plan quality; otherwise draft from provided context.",
35
+ "Do not edit repository files or run mutating commands unless the user explicitly asks for implementation.",
36
+ "If clarification is required, pause and ask through the web relay.",
37
+ "These instructions remain in effect until relay mode changes.",
38
+ ].join(" ");
39
+ }
40
+ if (mode === "ask") {
41
+ return [
42
+ "Prioritize clarification questions before doing any implementation work.",
43
+ "If the request is ambiguous or underspecified, pause and ask through the web relay before making assumptions.",
44
+ "Do not make broad assumptions when a question would materially change the result.",
45
+ "These instructions remain in effect until relay mode changes.",
46
+ ].join(" ");
47
+ }
48
+ if (mode === "autopilot") {
49
+ return [
50
+ "Act directly on the request and use tools when needed.",
51
+ "Keep moving unless user input is truly blocking.",
52
+ "These instructions remain in effect until relay mode changes.",
53
+ ].join(" ");
54
+ }
55
+ return [
56
+ "Proceed as an interactive coding agent and use tools as needed.",
57
+ "If you need clarification, pause and ask through the web relay instead of stalling silently.",
58
+ "These instructions remain in effect until relay mode changes.",
59
+ ].join(" ");
60
+ }
61
+
62
+ export function buildModeMarker(mode) {
63
+ return `[Relay mode: ${normalizeRelayMode(mode)}]`;
64
+ }
65
+
66
+ export function buildModePrompt(mode, toolInstructions = "", options = {}) {
67
+ const normalizedMode = normalizeRelayMode(mode);
68
+ const includeInstructions = options?.includeInstructions !== false;
69
+ const parts = [buildModeMarker(normalizedMode)];
70
+ if (includeInstructions) {
71
+ parts.push(buildModeInstructionText(normalizedMode));
72
+ if (toolInstructions) {
73
+ parts.push(toolInstructions);
74
+ }
75
+ }
76
+ return parts.join(" ");
77
+ }
78
+
79
+ export function buildPromptWithMode(message, toolInstructions = "", options = {}) {
80
+ const modePrompt = buildModePrompt(message?.relayMode, toolInstructions, options);
81
+ const body = buildPrompt(message);
82
+ return [modePrompt, body].filter(Boolean).join(" ");
83
+ }
84
+
85
+ /**
86
+ * The relay's prompt prefix builder: tool guidance rides along only when the
87
+ * relay mode changed, so repeat turns in one mode stay cheap.
88
+ */
89
+ export function createRelayPromptBuilder({
90
+ toolInstructions = "",
91
+ getPreviewInstructions = null,
92
+ } = {}) {
93
+ let lastPromptedRelayMode = null;
94
+ return async function buildPromptWithRelayContext(message) {
95
+ const relayMode = normalizeRelayMode(message?.relayMode);
96
+ const includeInstructions = lastPromptedRelayMode !== relayMode;
97
+ let instructions = "";
98
+ if (includeInstructions) {
99
+ // Guidance is advisory: a preview lookup that fails must cost the block,
100
+ // never the turn.
101
+ const previewBlock = typeof getPreviewInstructions === "function"
102
+ ? await Promise.resolve().then(getPreviewInstructions).catch(() => "")
103
+ : "";
104
+ instructions = applyPreviewInstructions(toolInstructions, previewBlock);
105
+ }
106
+ const prompt = buildPromptWithMode({ ...message, relayMode }, instructions, { includeInstructions });
107
+ lastPromptedRelayMode = relayMode;
108
+ return prompt;
109
+ };
110
+ }
111
+
112
+ export function stripPromptPrefix(text, promptPrefix = "") {
113
+ const value = String(text || "").trim();
114
+ if (!value) return "";
115
+ const prompt = String(promptPrefix || "").trim();
116
+ if (!prompt) return value;
117
+ if (!value.startsWith(prompt)) return value;
118
+ const remainder = value.slice(prompt.length).trim();
119
+ return remainder || "";
120
+ }
121
+
122
+ function escapeRegExp(value) {
123
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
124
+ }
125
+
126
+ function whitespaceFlexiblePattern(value) {
127
+ return String(value || "")
128
+ .trim()
129
+ .split(/\s+/)
130
+ .filter(Boolean)
131
+ .map((part) => escapeRegExp(part))
132
+ .join("\\s+");
133
+ }
134
+
135
+ function stripPromptPrefixFlexible(text, promptPrefix = "") {
136
+ const value = String(text || "").trim();
137
+ const prompt = String(promptPrefix || "").trim();
138
+ if (!value || !prompt) return value;
139
+ const pattern = new RegExp(`^${whitespaceFlexiblePattern(prompt)}\\s*`, "i");
140
+ const stripped = value.replace(pattern, "").trim();
141
+ return stripped || "";
142
+ }
143
+
144
+ export function stripPromptContextPrefix(text, message, toolInstructions = "", promptPrefix = "") {
145
+ const value = String(text || "").trim();
146
+ if (!value) return "";
147
+ const candidates = [
148
+ String(promptPrefix || "").trim(),
149
+ buildPromptWithMode(message, toolInstructions),
150
+ buildPromptWithMode(message, toolInstructions, { includeInstructions: false }),
151
+ buildModeMarker(message?.relayMode),
152
+ ].filter(Boolean);
153
+ for (const candidate of candidates) {
154
+ const stripped = stripPromptPrefix(value, candidate);
155
+ if (stripped !== value) return stripped;
156
+ const flexibleStripped = stripPromptPrefixFlexible(value, candidate);
157
+ if (flexibleStripped !== value) return flexibleStripped;
158
+ }
159
+ return value;
160
+ }
@@ -0,0 +1,44 @@
1
+ // Glue between a steering-capable session runner and the relay socket link —
2
+ // shared by every worker whose runner exposes a delivery gate (the Claude
3
+ // session runner, the Copilot SDK runner). Kept out of the worker entry files
4
+ // (which run main() on import) so the wiring itself is testable.
5
+
6
+ import { requeueOwedRows } from '../worker-crash-guard.mjs';
7
+
8
+ /**
9
+ * The runner reports every flip of its delivery gate; the link turns them
10
+ * into relay readiness frames. `attach` is late-bound because the runner is
11
+ * built before the link (the link's probes call into the runner).
12
+ */
13
+ export function createRunnerLinkBridge() {
14
+ let link = null;
15
+ return {
16
+ attach(nextLink) {
17
+ link = nextLink || null;
18
+ },
19
+ onDeliveryReadinessChange(ready) {
20
+ if (!link) return;
21
+ if (ready) void link.notifyReady('steering-resumed');
22
+ else link.notifyUnready('steering-held');
23
+ },
24
+ // Only a relay that advertised the capability in server.hello understands
25
+ // the penalty-free hand-back; before any hello, assume an older relay.
26
+ canHandBackHeldDelivery() {
27
+ return Boolean(link?.serverSupports?.('steering-held'));
28
+ },
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Worker shutdown (SIGTERM/SIGINT): rows whose prompt the engine already
34
+ * consumed and whose settle has not landed are failed terminally before the
35
+ * process goes — left behind, the relay's dead-worker recovery would requeue
36
+ * them and run them a second time. Bounded like the crash guard: a shutdown
37
+ * that hangs is worse than none. Other owed rows are left to the relay's
38
+ * recovery, as before.
39
+ */
40
+ export async function failSettlingRowsOnShutdown({ api, runner, timeoutMs = 2_000 } = {}) {
41
+ const settling = (runner?.getActiveQueueMessageIds?.() || []).filter((entry) => entry?.terminalError);
42
+ await requeueOwedRows({ api, entries: settling, timeoutMs });
43
+ return settling.length;
44
+ }
@@ -0,0 +1,170 @@
1
+ function readErrorText(error) {
2
+ return String(error?.message || error || "").trim();
3
+ }
4
+
5
+ function extractFirstMatch(text, pattern) {
6
+ const match = text.match(pattern);
7
+ return String(match?.[1] || "").trim() || null;
8
+ }
9
+
10
+ export function toKebabToken(value) {
11
+ return String(value || "")
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-+|-+$/g, "") || null;
16
+ }
17
+
18
+ // The runtime's own vocabulary for "the billing window is spent". The Copilot
19
+ // SDK's `ErrorData.errorCode` documents `quota_exceeded` and
20
+ // `session_quota_exceeded`; `errorType` documents the coarser `quota`
21
+ // category; HTTP 402 is the transport-level form of the same thing.
22
+ const QUOTA_ERROR_CODES = new Set([
23
+ "quota_exceeded",
24
+ "session_quota_exceeded",
25
+ "quota-exceeded",
26
+ "session-quota-exceeded",
27
+ "quota",
28
+ ]);
29
+
30
+ /**
31
+ * True when a runtime flagged quota exhaustion STRUCTURALLY, independent of
32
+ * how the prose was worded. This exists because the prose is the one part
33
+ * GitHub rewords freely ("premium requests" → "AI credits" → "monthly quota"),
34
+ * and a quota failure misfiled as retryable burns the retry budget against a
35
+ * window that cannot reset until the next billing period.
36
+ *
37
+ * Accepts the loose shapes callers actually hold: an `Error` with `code` /
38
+ * `statusCode`, or a runtime error payload with `errorCode` / `errorType` /
39
+ * `statusCode`.
40
+ */
41
+ export function isStructuredQuotaError(source) {
42
+ if (!source || typeof source !== "object") return false;
43
+ for (const field of ["code", "errorCode", "errorType"]) {
44
+ const token = String(source[field] || "").trim().toLowerCase();
45
+ if (token && QUOTA_ERROR_CODES.has(token)) return true;
46
+ }
47
+ const status = Number(source.statusCode ?? source.status);
48
+ return status === 402;
49
+ }
50
+
51
+ const QUOTA_EXHAUSTED = {
52
+ code: "quota-exhausted",
53
+ message: "GitHub Copilot has no AI credits left for this billing window, so the request was rejected.",
54
+ guidance: "Open Check Usage for the reset time and retry after the reset, or switch this conversation to another provider.",
55
+ };
56
+
57
+ /**
58
+ * `hint` carries structured signals that outrank the prose. Only quota is
59
+ * modelled today; the prose branches below are unchanged and remain the sole
60
+ * classifier when no hint is supplied, which is the extension's path.
61
+ */
62
+ function classifyTerminalError(text, hint = {}) {
63
+ // Checked before the prose so a structurally-flagged quota error lands in
64
+ // the non-retryable branch however mildly it was worded.
65
+ if (hint.quota === true) return QUOTA_EXHAUSTED;
66
+
67
+ const lower = text.toLowerCase();
68
+ if (!lower) return null;
69
+
70
+ if (lower.includes("no tool output found for function call")) {
71
+ return {
72
+ code: "missing-tool-output",
73
+ message: "No tool output was returned for a required function call.",
74
+ guidance: "Retry the message. If this keeps happening, restart the relay and include the error code.",
75
+ };
76
+ }
77
+ if (lower.includes("tool call") && (lower.includes("not found") || lower.includes("missing"))) {
78
+ return {
79
+ code: "tool-call-missing",
80
+ message: "A required tool call was missing in the runtime response.",
81
+ guidance: "Retry the message. If it repeats, restart the relay and include the error code.",
82
+ };
83
+ }
84
+ if (lower.includes("tool output") && lower.includes("invalid")) {
85
+ return {
86
+ code: "invalid-tool-output",
87
+ message: "Tool output returned from the runtime was invalid.",
88
+ guidance: "Retry the message. If it repeats, restart the relay and include the error code.",
89
+ };
90
+ }
91
+ // Monthly/plan quota exhaustion is not transient: retrying before the
92
+ // billing window resets can never succeed. Distinct from "rate limit"
93
+ // (per-minute throttling), which stays retryable below.
94
+ if ((lower.includes("quota") && (lower.includes("exceeded") || lower.includes("exhaust")))
95
+ || lower.includes("out of ai credits")
96
+ || lower.includes("premium request allowance")) {
97
+ return QUOTA_EXHAUSTED;
98
+ }
99
+ if ((lower.includes("capierror") || lower.includes("http 400") || lower.includes("status 400") || /^400\b/.test(lower))
100
+ && !lower.includes("timeout")
101
+ && !lower.includes("temporar")
102
+ && !lower.includes("rate limit")) {
103
+ return {
104
+ code: "request-invalid",
105
+ message: "The runtime rejected the request as invalid and non-retryable.",
106
+ guidance: "Retry after adjusting the request. If it persists, restart the relay and include the error code.",
107
+ };
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * `options` lets a caller that holds structured error fields (an SDK error
115
+ * payload rather than a thrown `Error`) pass them in explicitly:
116
+ * `{ quota: true }`, or `{ errorCode, errorType, statusCode }` to be judged
117
+ * here. Structured fields carried on the error object itself (`error.code`,
118
+ * `error.statusCode`) are read as well, so plain HTTP-ish errors classify
119
+ * without the caller doing anything.
120
+ */
121
+ export function normalizeTerminalSendAndWaitError(error, options = {}) {
122
+ const detail = readErrorText(error);
123
+ const quota = options.quota === true
124
+ || isStructuredQuotaError(options)
125
+ || isStructuredQuotaError(error);
126
+ const base = classifyTerminalError(detail, { quota });
127
+ if (!base) return null;
128
+ const functionCallId = extractFirstMatch(detail, /function call\s+([a-z0-9_-]+)/i)
129
+ || extractFirstMatch(detail, /\b(call_[a-z0-9_-]+)/i);
130
+ // GitHub request ids are colon-separated hex segments (AFAC:3C07CD:…).
131
+ const requestId = extractFirstMatch(detail, /\brequest(?:\s+id|_id)?[:=]\s*([a-z0-9_-]+(?::[a-z0-9_-]+)*)/i)
132
+ || extractFirstMatch(detail, /\b(req_[a-z0-9_-]+)/i);
133
+ return {
134
+ terminal: true,
135
+ code: base.code,
136
+ stableCode: `relay.${base.code}`,
137
+ message: base.message,
138
+ guidance: base.guidance,
139
+ detail: detail || "unknown error",
140
+ functionCallId,
141
+ requestId,
142
+ classificationHint: toKebabToken(error?.code || error?.name || null),
143
+ };
144
+ }
145
+
146
+ export function isTerminalSendAndWaitError(error, options = {}) {
147
+ return !!normalizeTerminalSendAndWaitError(error, options);
148
+ }
149
+
150
+ export function buildTerminalFailureText(error, options = {}) {
151
+ const normalized = normalizeTerminalSendAndWaitError(error, options) || {
152
+ stableCode: "relay.unknown-terminal",
153
+ message: "The relay runtime hit a terminal error and could not complete this turn.",
154
+ guidance: "Retry the message.",
155
+ detail: readErrorText(error) || "unknown error",
156
+ functionCallId: null,
157
+ requestId: null,
158
+ };
159
+ const ids = [
160
+ normalized.functionCallId ? `functionCallId=${normalized.functionCallId}` : null,
161
+ normalized.requestId ? `requestId=${normalized.requestId}` : null,
162
+ ].filter(Boolean);
163
+ return [
164
+ normalized.message,
165
+ `Error code: ${normalized.stableCode}.`,
166
+ ids.length ? `IDs: ${ids.join(", ")}.` : null,
167
+ normalized.guidance,
168
+ `Details: ${normalized.detail}`,
169
+ ].filter(Boolean).join(" ");
170
+ }