@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,66 @@
1
+ // The hidden note a steered message carries to the model.
2
+ //
3
+ // A message the user sends while a turn is running is pushed into that turn
4
+ // (steering). When it lands before the model has produced anything, the model
5
+ // sees two user messages at once — and in the 2026-09-25 burn-in gpt-5.4-mini
6
+ // answered only the newest and silently dropped the original request (the
7
+ // runtime's own event log confirmed nothing was lost by the relay). The note
8
+ // tells the model the new message is an addition, not a replacement, unless
9
+ // the user says otherwise. Only the model sees it: the relay stores and shows
10
+ // the user's own text.
11
+ //
12
+ // Shared by the Claude and Copilot SDK workers (maintainer decision, 2026-09-25: both).
13
+
14
+ // Worded to stay true whether the runtime folds the message into the running
15
+ // turn or runs it as a turn of its own after that one finished: "if not
16
+ // finished yet" never asks the model to redo work it already completed. The
17
+ // opening words are the stable handle the prompt sanitizers match on
18
+ // (services/relay-prompt-sanitizer.mjs and its browser copy).
19
+ export const STEERED_MESSAGE_NOTE = '[Sent while you were still working on my previous message. '
20
+ + 'If that request is not finished yet, finish it too; do not drop it unless this message says so.]';
21
+
22
+ // The note LEADS the user's text, inside the same text block, and that
23
+ // position is load-bearing beyond the replay's shape. The Claude CLI runs a
24
+ // prompt as a slash command when the string, or its LAST text block trimmed,
25
+ // starts with "/" — and one dequeued mid-turn is deferred to a turn of its
26
+ // own, whose output the relay would misattribute. The relay itself runs only
27
+ // /compact and /preview as commands (any other "/x" is confirmed as text
28
+ // first), so a steered "/cmd …" must reach the model as plain text folded
29
+ // into the running turn, which the leading note guarantees. Appended, the
30
+ // prompt would still start with "/" and run as a command whose arguments
31
+ // include the note; as a separate leading block, the last block would still
32
+ // start with "/". A "/x" that is not steered (or is pushed into a continuation,
33
+ // which gets no note) is kept text by the Claude worker's slash-command guard
34
+ // instead (shared/slash-command-guard.mjs).
35
+
36
+ /** Prefix a plain-text prompt with the steer note. */
37
+ export function withSteerNote(text) {
38
+ const body = String(text ?? '');
39
+ return body.trim() ? `${STEERED_MESSAGE_NOTE}\n\n${body}` : STEERED_MESSAGE_NOTE;
40
+ }
41
+
42
+ /**
43
+ * Prefix a content-block array (Claude's user message content) with the note:
44
+ * into the first text block when there is one, so the block structure — and
45
+ * with it the replay the CLI echoes back — keeps its shape; otherwise as a new
46
+ * leading text block. Returns a new array; the input is not mutated.
47
+ */
48
+ export function withSteerNoteContent(content) {
49
+ if (typeof content === 'string') return withSteerNote(content);
50
+ if (!Array.isArray(content)) return content;
51
+ const index = content.findIndex((block) => block?.type === 'text');
52
+ if (index < 0) return [{ type: 'text', text: STEERED_MESSAGE_NOTE }, ...content];
53
+ return content.map((block, i) => (i === index ? { ...block, text: withSteerNote(block.text) } : block));
54
+ }
55
+
56
+ /**
57
+ * The user's own text from a prompt that may carry the steer note — what a
58
+ * worker compares a replayed message against. Note-agnostic matching: the
59
+ * replay the CLI echoes back carries the note, the relay row does not.
60
+ */
61
+ export function stripSteerNote(text) {
62
+ const value = String(text ?? '');
63
+ if (value === STEERED_MESSAGE_NOTE) return '';
64
+ const prefix = `${STEERED_MESSAGE_NOTE}\n\n`;
65
+ return value.startsWith(prefix) ? value.slice(prefix.length) : value;
66
+ }
@@ -0,0 +1,89 @@
1
+ // The terminal failure for a message whose prompt the agent already consumed
2
+ // but whose outcome could not be recorded. At-most-once: such a row is never
3
+ // requeued (a re-delivery would run the prompt a second time), so it fails for
4
+ // good with wording that tells the user where the answer went. Shared by the
5
+ // session workers (their own settle paths) and the relay (recovery paths that
6
+ // meet a row marked consumed).
7
+ //
8
+ // Provider-neutral: the wording names `agentLabel` ("the agent" unless the
9
+ // caller knows better — the Claude worker passes 'Claude').
10
+
11
+ export const STEER_SETTLE_FAILED_CODE = 'steer-settle-failed';
12
+ export const STEER_SETTLE_FAILED_STABLE_CODE = `relay.${STEER_SETTLE_FAILED_CODE}`;
13
+ export const DEFAULT_STEER_AGENT_LABEL = 'the agent';
14
+
15
+ function normalizeAgentLabel(value) {
16
+ const text = String(value || '').trim();
17
+ return text || DEFAULT_STEER_AGENT_LABEL;
18
+ }
19
+
20
+ // "the agent" → "The agent" at the start of a sentence; a proper name is
21
+ // left as it is.
22
+ function sentenceCase(label) {
23
+ return label.charAt(0).toUpperCase() + label.slice(1);
24
+ }
25
+
26
+ const VARIANTS = Object.freeze({
27
+ // Folded into a turn whose reply answered it (or the path is unknown — the
28
+ // relay's recovery paths cannot tell which).
29
+ folded: (label) => ({
30
+ message: `This message was already sent to ${label} — check the reply above.`,
31
+ guidance: 'Resend it only if it went unanswered.',
32
+ }),
33
+ // Handed off mid-reply: its own partial reply stands, and the rest of the
34
+ // answer continued on the next message.
35
+ handoff: (label) => ({
36
+ message: `${sentenceCase(label)} started answering this message, then carried on with your next one — check both replies.`,
37
+ guidance: 'Resend it only if something went unanswered.',
38
+ }),
39
+ // Steered into a turn the user stopped.
40
+ stopped: (label) => ({
41
+ message: `This message was sent to ${label} just before you stopped the turn, so it was not answered.`,
42
+ guidance: 'Resend it if you still want an answer.',
43
+ }),
44
+ // Pulled back out of the runtime's queue by the user (un-steer) — the
45
+ // prompt never ran — but the relay could not be told in time.
46
+ cancelled: (label) => ({
47
+ message: `This message was cancelled before ${label} started on it, so it was not answered.`,
48
+ guidance: 'Resend it if you still want an answer.',
49
+ }),
50
+ });
51
+
52
+ /**
53
+ * The name a provider's runtime goes by in the settle wording, for callers
54
+ * that only know the conversation's provider type (the relay's recovery
55
+ * paths). Workers that know exactly which runtime took the prompt pass the
56
+ * label themselves.
57
+ */
58
+ export function steerAgentLabelForProvider(providerType) {
59
+ switch (String(providerType || '').trim().toLowerCase()) {
60
+ case 'claude': return 'Claude';
61
+ case 'github':
62
+ case 'openai': return 'Copilot';
63
+ case 'cursor': return 'Cursor';
64
+ case 'grok': return 'Grok';
65
+ default: return DEFAULT_STEER_AGENT_LABEL;
66
+ }
67
+ }
68
+
69
+ function wordingFor(variant, agentLabel) {
70
+ const build = VARIANTS[variant] || VARIANTS.folded;
71
+ return build(normalizeAgentLabel(agentLabel));
72
+ }
73
+
74
+ export function steerSettleFailureText(variant = 'folded', agentLabel = DEFAULT_STEER_AGENT_LABEL) {
75
+ return wordingFor(variant, agentLabel).message;
76
+ }
77
+
78
+ export function buildSteerSettleFailure(message, { variant = 'folded', agentLabel = DEFAULT_STEER_AGENT_LABEL } = {}) {
79
+ const wording = wordingFor(variant, agentLabel);
80
+ return {
81
+ kind: STEER_SETTLE_FAILED_CODE,
82
+ code: STEER_SETTLE_FAILED_CODE,
83
+ stableCode: STEER_SETTLE_FAILED_STABLE_CODE,
84
+ message: wording.message,
85
+ guidance: wording.guidance,
86
+ failedAt: new Date().toISOString(),
87
+ queueMessageId: String(message?.id || '') || null,
88
+ };
89
+ }
@@ -0,0 +1,18 @@
1
+ // The settle stubs of steered messages the Claude CLI consumed without opening
2
+ // a turn of their own. Each is stamped with its own message kind, which is
3
+ // what the client renders from; the texts are shared so the relay's migration
4
+ // can recognise stubs saved before the kind existed.
5
+
6
+ /**
7
+ * A steer folded into the running turn and answered by that turn's reply.
8
+ * Stamped kind='folded' (before 0.9.3: kind='absorbed', which the client read
9
+ * as "the reply continues through the next message" and mis-merged the next
10
+ * ordinary message on reload).
11
+ */
12
+ export const STEER_FOLDED_TEXT = '_(Handled together with the previous reply — this message was steered into that turn.)_';
13
+
14
+ /**
15
+ * A steer pushed into a turn the user then stopped: never answered. Stamped
16
+ * kind='stopped' — the stable handle the client keys its Resend on.
17
+ */
18
+ export const STEER_STOPPED_TEXT = '_(Stopped with the turn — not answered.)_';
@@ -0,0 +1,50 @@
1
+ // Minimal version comparison for OAR's update check: releases are X.Y.Z or
2
+ // X.Y.Z-beta.N, nothing else. Not a general semver implementation on purpose —
3
+ // anything unparseable compares as "no update" instead of guessing.
4
+
5
+ export function parseSemverIsh(value) {
6
+ const text = String(value ?? '').trim();
7
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(text);
8
+ if (!match) return null;
9
+ const prerelease = match[4]
10
+ ? match[4].split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : part))
11
+ : null;
12
+ return {
13
+ major: Number(match[1]),
14
+ minor: Number(match[2]),
15
+ patch: Number(match[3]),
16
+ prerelease,
17
+ };
18
+ }
19
+
20
+ /** -1 | 0 | 1; a prerelease sorts below the release with the same triple. */
21
+ export function compareSemverIsh(a, b) {
22
+ const left = typeof a === 'object' && a !== null ? a : parseSemverIsh(a);
23
+ const right = typeof b === 'object' && b !== null ? b : parseSemverIsh(b);
24
+ if (!left || !right) return 0;
25
+ for (const key of ['major', 'minor', 'patch']) {
26
+ if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1;
27
+ }
28
+ if (!left.prerelease && !right.prerelease) return 0;
29
+ if (!left.prerelease) return 1;
30
+ if (!right.prerelease) return -1;
31
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
32
+ for (let i = 0; i < length; i += 1) {
33
+ const l = left.prerelease[i];
34
+ const r = right.prerelease[i];
35
+ if (l === undefined) return -1;
36
+ if (r === undefined) return 1;
37
+ if (l === r) continue;
38
+ if (typeof l === 'number' && typeof r === 'number') return l < r ? -1 : 1;
39
+ if (typeof l === 'number') return -1; // numeric identifiers sort first
40
+ if (typeof r === 'number') return 1;
41
+ return l < r ? -1 : 1;
42
+ }
43
+ return 0;
44
+ }
45
+
46
+ export function channelForVersion(version) {
47
+ const parsed = parseSemverIsh(version);
48
+ const first = parsed?.prerelease?.[0];
49
+ return typeof first === 'string' && first.toLowerCase().startsWith('beta') ? 'beta' : 'stable';
50
+ }
@@ -11,6 +11,45 @@
11
11
  * The requeue is strictly bounded: a crash handler that hangs is worse than
12
12
  * no handler at all.
13
13
  */
14
+ /**
15
+ * Post a bounded, fenced requeue for each owed row. Entries are plain ids or
16
+ * { id, attemptId, terminalError } objects: the attempt id fences the requeue
17
+ * (a row that has moved on to another attempt must not be yanked back by a
18
+ * dying predecessor), and a `terminalError` makes the requeue route fail the
19
+ * row outright — for a row whose prompt was already consumed, which must not
20
+ * be requeued and run a second time. Never waits longer than `timeoutMs`.
21
+ */
22
+ export async function requeueOwedRows({ api, entries = [], timeoutMs = 2_000 } = {}) {
23
+ const seen = new Set();
24
+ const owed = (Array.isArray(entries) ? entries : [])
25
+ .map((entry) => (entry && typeof entry === 'object'
26
+ ? {
27
+ id: String(entry.id || '').trim(),
28
+ attemptId: String(entry.attemptId || '').trim() || null,
29
+ terminalError: entry.terminalError && typeof entry.terminalError === 'object' ? entry.terminalError : null,
30
+ }
31
+ : { id: String(entry || '').trim(), attemptId: null, terminalError: null }))
32
+ .filter((entry) => {
33
+ if (!entry.id || seen.has(entry.id)) return false;
34
+ seen.add(entry.id);
35
+ return true;
36
+ });
37
+ if (!owed.length || typeof api !== 'function') return;
38
+ let timer = null;
39
+ await Promise.race([
40
+ Promise.allSettled(owed.map((entry) => api('POST', '/api/requeue', {
41
+ messageId: entry.id,
42
+ ...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
43
+ ...(entry.terminalError ? { terminalError: entry.terminalError } : {}),
44
+ }))),
45
+ new Promise((resolve) => {
46
+ timer = setTimeout(resolve, Math.max(0, Number(timeoutMs) || 0));
47
+ timer.unref?.();
48
+ }),
49
+ ]);
50
+ clearTimeout(timer);
51
+ }
52
+
14
53
  export function installWorkerCrashGuard({
15
54
  api,
16
55
  workerName = 'session-worker',
@@ -28,20 +67,7 @@ export function installWorkerCrashGuard({
28
67
  logError(`${workerName} ${kind}:`, error?.stack || error?.message || error);
29
68
  (async () => {
30
69
  try {
31
- const ids = [...new Set(
32
- (getActiveQueueMessageIds() || [])
33
- .map((id) => String(id || '').trim())
34
- .filter(Boolean),
35
- )];
36
- if (ids.length && typeof api === 'function') {
37
- await Promise.race([
38
- Promise.allSettled(ids.map((id) => api('POST', '/api/requeue', { messageId: id }))),
39
- new Promise((resolve) => {
40
- const timer = setTimeout(resolve, Math.max(0, Number(requeueTimeoutMs) || 0));
41
- timer.unref?.();
42
- }),
43
- ]);
44
- }
70
+ await requeueOwedRows({ api, entries: getActiveQueueMessageIds() || [], timeoutMs: requeueTimeoutMs });
45
71
  } catch {}
46
72
  try { onBeforeExit(); } catch {}
47
73
  exit(1);
@@ -0,0 +1,34 @@
1
+ export function createApiClient({ serverUrl, token, getHeaders }) {
2
+ return async function api(method, routePath, body) {
3
+ const url = `${serverUrl}${routePath}`;
4
+ const extraHeaders = typeof getHeaders === "function" ? (getHeaders() || {}) : {};
5
+ const opts = {
6
+ method,
7
+ headers: {
8
+ "Content-Type": "application/json",
9
+ Authorization: `Bearer ${token}`,
10
+ ...extraHeaders,
11
+ },
12
+ ...(method !== "GET" ? { body: JSON.stringify(body || {}) } : {}),
13
+ };
14
+
15
+ const res = await fetch(url, opts);
16
+ if (!res.ok) {
17
+ const rawText = String(await res.text().catch(() => "")).trim();
18
+ let detail = rawText;
19
+ if (rawText) {
20
+ try {
21
+ const payload = JSON.parse(rawText);
22
+ detail = String(payload?.error || payload?.message || rawText).trim();
23
+ } catch {
24
+ detail = rawText;
25
+ }
26
+ }
27
+ const error = new Error(`HTTP ${res.status} ${routePath}${detail ? `: ${detail}` : ""}`);
28
+ error.status = res.status;
29
+ error.detail = detail;
30
+ throw error;
31
+ }
32
+ return res.json();
33
+ };
34
+ }
@@ -0,0 +1,104 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ function toAbsolutePath(value) {
6
+ if (!value) return "";
7
+ return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
8
+ }
9
+
10
+ function looksLikeServerDir(serverDir) {
11
+ if (!serverDir) return false;
12
+ return fs.existsSync(path.join(serverDir, "server.js")) && fs.existsSync(path.join(serverDir, "config.json"));
13
+ }
14
+
15
+ export function resolveRelayPaths(importMetaUrl) {
16
+ const __dirname = path.dirname(fileURLToPath(importMetaUrl));
17
+ const projectServerDir = path.resolve(__dirname, "../../../server");
18
+ const cwdServerDir = path.resolve(process.cwd(), "server");
19
+ const envRoot = toAbsolutePath(process.env.COPILOT_WEB_RELAY_ROOT || "");
20
+ const envServerDir = toAbsolutePath(process.env.COPILOT_WEB_RELAY_SERVER_DIR || "");
21
+ const envConfigPath = toAbsolutePath(process.env.COPILOT_WEB_RELAY_CONFIG || "");
22
+ const envToolsPath = toAbsolutePath(process.env.COPILOT_WEB_RELAY_TOOLS || "");
23
+ const envLogDir = toAbsolutePath(process.env.COPILOT_WEB_RELAY_LOG_DIR || "");
24
+
25
+ const resolvedServerDir =
26
+ (looksLikeServerDir(envServerDir) && envServerDir) ||
27
+ (looksLikeServerDir(path.join(envRoot, "server")) && path.join(envRoot, "server")) ||
28
+ (looksLikeServerDir(projectServerDir) && projectServerDir) ||
29
+ (looksLikeServerDir(cwdServerDir) && cwdServerDir) ||
30
+ projectServerDir;
31
+
32
+ const SERVER_DIR = resolvedServerDir;
33
+ const CONFIG_PATH = envConfigPath || path.resolve(SERVER_DIR, "config.json");
34
+ const RELAY_TOOLS_PATH = envToolsPath || path.resolve(SERVER_DIR, "relay-tools.md");
35
+ const LOG_DIR = envLogDir || path.resolve(SERVER_DIR, "logs");
36
+ const SERVER_LOG_PATH = path.resolve(LOG_DIR, "server.log");
37
+ const SERVER_ERR_PATH = path.resolve(LOG_DIR, "server-err.log");
38
+
39
+ return {
40
+ __dirname,
41
+ CONFIG_PATH,
42
+ RELAY_TOOLS_PATH,
43
+ SERVER_DIR,
44
+ LOG_DIR,
45
+ SERVER_LOG_PATH,
46
+ SERVER_ERR_PATH,
47
+ };
48
+ }
49
+
50
+ export function loadTokenFromConfig(configPath) {
51
+ try {
52
+ const cfg = loadRelayConfig(configPath);
53
+ return String(cfg.authToken || "").trim();
54
+ } catch {
55
+ return "";
56
+ }
57
+ }
58
+
59
+ export function loadRelayConfig(configPath) {
60
+ try {
61
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
62
+ return parsed && typeof parsed === "object" ? parsed : {};
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ function normalizeLoopbackHost(value) {
69
+ const host = String(value || "").trim();
70
+ if (!host || host === "0.0.0.0" || host === "::") return "127.0.0.1";
71
+ if (host === "localhost") return "127.0.0.1";
72
+ return host;
73
+ }
74
+
75
+ export function resolveRelayServerUrl({ configPath, env = process.env } = {}) {
76
+ const explicitUrl = String(env.COPILOT_WEB_RELAY_SERVER_URL || "").trim();
77
+ if (explicitUrl) {
78
+ const url = new URL(explicitUrl);
79
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
80
+ throw new Error(`invalid-relay-server-url:${url.protocol}`);
81
+ }
82
+ url.pathname = "";
83
+ url.search = "";
84
+ url.hash = "";
85
+ return url.toString().replace(/\/$/, "");
86
+ }
87
+
88
+ const config = loadRelayConfig(configPath);
89
+ const configuredPort = Number.parseInt(String(config.port || ""), 10);
90
+ const port = Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65535
91
+ ? configuredPort
92
+ : 3333;
93
+ const host = normalizeLoopbackHost(config.host);
94
+ const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
95
+ return `http://${formattedHost}:${port}`;
96
+ }
97
+
98
+ export function loadRelayInstructionsFromFile(relayToolsPath) {
99
+ try {
100
+ return String(fs.readFileSync(relayToolsPath, "utf8")).trim();
101
+ } catch {
102
+ return "";
103
+ }
104
+ }
@@ -0,0 +1,109 @@
1
+ export function createHeartbeatController({
2
+ api,
3
+ pollMs,
4
+ getSessionReady,
5
+ getHeartbeatTimer,
6
+ setHeartbeatTimer,
7
+ getActiveQueueMessageId,
8
+ // Optional: every queue message the worker currently owns work for (a
9
+ // persistent-process worker can hold a running turn, a delivered message
10
+ // queued behind it, and a background continuation at once). Each reported
11
+ // id gets its owner lease refreshed; unreported owned rows are recovered.
12
+ getActiveQueueMessageIds,
13
+ // Optional: the worker's steering snapshot ({turnActive, canSteer,
14
+ // holdReason}) for the composer. Workers that do not steer leave it unset
15
+ // and the heartbeat body is unchanged.
16
+ getSteeringState,
17
+ // Optional: rows the worker gave up settling ([{id, attemptId,
18
+ // terminalError}]); the relay fails them and names them back in
19
+ // `settleFailedHandled`, which `onSettleFailedHandled` receives.
20
+ getSettleFailed,
21
+ onSettleFailedHandled = () => {},
22
+ // requestPulse's window: one turn transition can flip the steering
23
+ // snapshot several times in quick succession, and each flip only needs to
24
+ // reach the relay once.
25
+ pulseCoalesceMs = 300,
26
+ setTimeoutImpl = setTimeout,
27
+ clearTimeoutImpl = clearTimeout,
28
+ }) {
29
+ let requestedPulseTimer = null;
30
+
31
+ async function pulseHeartbeat() {
32
+ if (!getSessionReady()) return false;
33
+ try {
34
+ const activeQueueMessageId = typeof getActiveQueueMessageId === "function"
35
+ ? String(getActiveQueueMessageId() || "").trim()
36
+ : "";
37
+ const activeQueueMessageIds = typeof getActiveQueueMessageIds === "function"
38
+ ? (getActiveQueueMessageIds() || []).map((id) => String(id || "").trim()).filter(Boolean)
39
+ : [];
40
+ const steering = typeof getSteeringState === "function"
41
+ ? getSteeringState()
42
+ : null;
43
+ const settleFailed = typeof getSettleFailed === "function"
44
+ ? (getSettleFailed() || []).filter((entry) => entry && String(entry.id || "").trim())
45
+ : [];
46
+ const body = {
47
+ ...(activeQueueMessageId ? { activeQueueMessageId } : {}),
48
+ ...(activeQueueMessageIds.length ? { activeQueueMessageIds } : {}),
49
+ ...(steering && typeof steering === "object" ? { steering } : {}),
50
+ ...(settleFailed.length ? { settleFailed } : {}),
51
+ };
52
+ const response = await api("POST", "/api/heartbeat", body);
53
+ // Failed by the relay, or skipped because this worker has no claim on
54
+ // the row: either way the worker releases it.
55
+ const handled = [
56
+ ...(Array.isArray(response?.settleFailedHandled) ? response.settleFailedHandled : []),
57
+ ...(Array.isArray(response?.settleFailedSkipped) ? response.settleFailedSkipped : []),
58
+ ];
59
+ if (handled.length) {
60
+ try { onSettleFailedHandled(handled); } catch {}
61
+ }
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * An out-of-cycle pulse for state the relay should see before the next
70
+ * periodic one (the composer's steering snapshot, a relay that just came
71
+ * up). Requests inside one window share a single pulse, sent at its end so
72
+ * it carries the settled state.
73
+ */
74
+ function requestPulse() {
75
+ if (requestedPulseTimer) return;
76
+ requestedPulseTimer = setTimeoutImpl(() => {
77
+ requestedPulseTimer = null;
78
+ void pulseHeartbeat();
79
+ }, pulseCoalesceMs);
80
+ requestedPulseTimer?.unref?.();
81
+ }
82
+
83
+ function startHeartbeat() {
84
+ if (getHeartbeatTimer()) return;
85
+ void pulseHeartbeat();
86
+ const timer = setInterval(() => {
87
+ void pulseHeartbeat();
88
+ }, pollMs);
89
+ setHeartbeatTimer(timer);
90
+ }
91
+
92
+ function stopHeartbeat() {
93
+ if (requestedPulseTimer) {
94
+ clearTimeoutImpl(requestedPulseTimer);
95
+ requestedPulseTimer = null;
96
+ }
97
+ const timer = getHeartbeatTimer();
98
+ if (!timer) return;
99
+ clearInterval(timer);
100
+ setHeartbeatTimer(null);
101
+ }
102
+
103
+ return {
104
+ pulseHeartbeat,
105
+ requestPulse,
106
+ startHeartbeat,
107
+ stopHeartbeat,
108
+ };
109
+ }