@oar-sh/oar 0.9.13 → 0.9.15

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 (74) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +396 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +677 -103
  7. package/package.json +7 -5
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  9. package/server/claude-worker/claude-session-process.mjs +39 -13
  10. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  12. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  13. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  14. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  17. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  19. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  20. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  21. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  22. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  23. package/server/public/app/api-client.js +3 -1
  24. package/server/public/app/ask-user-view.js +29 -4
  25. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  26. package/server/public/app/question-answer-rule.mjs +15 -0
  27. package/server/public/app/question-multi-select.mjs +9 -2
  28. package/server/public/app/relay-event-view.mjs +5 -1
  29. package/server/public/app/settings-modal.js +17 -3
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +49 -16
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +18 -5
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +415 -45
  61. package/server/services/windows-boot-autostart-service.mjs +962 -185
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/.github/extensions/web-relay/README.md +0 -190
  73. package/server/README.md +0 -2187
  74. package/server/uploads/image_input.md +0 -540
@@ -14,6 +14,7 @@ import {
14
14
  isOarMcpServerDisabled,
15
15
  } from '../mcp/oar-mcp-server.mjs';
16
16
  import { WORKER_LOG_FILE_ENV } from '../../shared/worker-runtime/worker-log-file.mjs';
17
+ import { missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
17
18
  import {
18
19
  describeSpawnEnvironment,
19
20
  envPathKey,
@@ -213,6 +214,9 @@ export function applyCursorProviderEnvironment(env = {}, {
213
214
  enabled = false,
214
215
  model = '',
215
216
  apiKey = '',
217
+ // The relay's resolved, absolute data dir: the worker keeps its agent stores
218
+ // there, and must not resolve a relative one or the config's own again.
219
+ dataDir = '',
216
220
  } = {}) {
217
221
  const next = { ...env };
218
222
  if (resolveWorkerKind(next) === 'cursor') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
@@ -225,6 +229,8 @@ export function applyCursorProviderEnvironment(env = {}, {
225
229
  next.CURSOR_API_KEY = normalizedApiKey;
226
230
  const normalizedModel = normalizeText(model);
227
231
  if (normalizedModel) next.CURSOR_RELAY_MODEL = normalizedModel;
232
+ const normalizedDataDir = normalizeText(dataDir);
233
+ if (normalizedDataDir) next.COPILOT_WEB_RELAY_DATA_DIR = path.resolve(normalizedDataDir);
228
234
  return next;
229
235
  }
230
236
 
@@ -717,6 +723,9 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
717
723
  'COPILOT_WEB_RELAY_CONFIG',
718
724
  'COPILOT_WEB_RELAY_TOOLS',
719
725
  'COPILOT_WEB_RELAY_LOG_DIR',
726
+ // The Cursor worker keeps its agent stores in the relay's data dir
727
+ // (applyCursorProviderEnvironment).
728
+ 'COPILOT_WEB_RELAY_DATA_DIR',
720
729
  'COPILOT_WEB_RELAY_CLI_EXECUTABLE',
721
730
  'COPILOT_WEB_RELAY_EXTENSION_BOOTSTRAP_PATH',
722
731
  'COPILOT_SDK_PATH',
@@ -847,6 +856,19 @@ export function describeWindowsStartFailure(state, { spawnCommand = 'cmd.exe', p
847
856
  return null;
848
857
  }
849
858
 
859
+ /**
860
+ * A spawn whose folder is gone fails with ENOENT (or ENOTDIR), which reads as
861
+ * a missing program (`node`, `cmd.exe`). Asked only after a spawn failed: the folder
862
+ * is named instead, or null when it is there. Exported for the tests.
863
+ * (A tmux launch never gets here: `new-session -c` falls back to the home
864
+ * folder, and the worker reports the folder on the turn.)
865
+ */
866
+ export function describeLaunchFolderFailure(state, folder, { check = missingWorkingDirectoryError } = {}) {
867
+ if (state?.error?.code !== 'ENOENT' && state?.error?.code !== 'ENOTDIR') return null;
868
+ const missing = check(folder);
869
+ return missing ? `worker-spawn-failed: ${missing.message}` : null;
870
+ }
871
+
850
872
  function buildWorkerLaunchEnv({ processCwd, workspaceRoot, env = process.env } = {}) {
851
873
  const launchProcessCwd = String(processCwd || '').trim();
852
874
  const launchWorkspaceRoot = String(workspaceRoot || '').trim() || launchProcessCwd;
@@ -900,6 +922,8 @@ export async function launchSessionCli({
900
922
  // Where a launch that went wrong explains itself (one line per call).
901
923
  log = null,
902
924
  locateExecutableImpl = locateExecutable,
925
+ // Asked after a failed spawn whether its folder is gone (the error, or null).
926
+ checkWorkingDirectoryImpl = missingWorkingDirectoryError,
903
927
  } = {}) {
904
928
  const target = String(targetSessionId || '').trim();
905
929
  if (!target) throw new Error('missing-target-session-id');
@@ -1154,7 +1178,8 @@ export async function launchSessionCli({
1154
1178
  // `cmd /c start` returns at once when the program started. Still running,
1155
1179
  // or ended with an error, means it did not: name the program, and log the
1156
1180
  // command and the PATH it was looked up on.
1157
- const failure = describeWindowsStartFailure(childState, { spawnCommand, program: startedProgram });
1181
+ const failure = describeLaunchFolderFailure(childState, launchProcessCwd, { check: checkWorkingDirectoryImpl })
1182
+ || describeWindowsStartFailure(childState, { spawnCommand, program: startedProgram });
1158
1183
  if (failure) {
1159
1184
  if (childState.tracked && !childState.exited) {
1160
1185
  try { child.kill?.(); } catch {}
@@ -1176,6 +1201,11 @@ export async function launchSessionCli({
1176
1201
  // A failed spawn reports its error on the next tick.
1177
1202
  await new Promise((resolve) => setImmediate(resolve));
1178
1203
  const reason = childState.error?.code || childState.error?.message || '';
1204
+ const folderFailure = describeLaunchFolderFailure(childState, launchProcessCwd, { check: checkWorkingDirectoryImpl });
1205
+ if (folderFailure) {
1206
+ explain(`could not start ${spawnCommand}: ${folderFailure}`);
1207
+ throw new Error(folderFailure);
1208
+ }
1179
1209
  explain(`could not start ${spawnCommand}${reason ? ` (${reason})` : ''}; ${spawnEnvironmentText()}`);
1180
1210
  throw new Error(`worker-spawn-unhealthy:missing-pid${reason ? ` (${spawnCommand}: ${reason})` : ''}`);
1181
1211
  }
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ // The stale-turn sweep: a `processing` row whose worker went silent, or whose
4
+ // turn ran past the max turn duration, goes back to the queue. Which rows
5
+ // qualify is decided by the repository (listRecoverableProcessing); this
6
+ // module decides what happens to them: continuations are torn down, consumed
7
+ // rows fail terminally, rows out of retry budget fail, the rest are requeued
8
+ // and announced.
9
+
10
+ /**
11
+ * `failRecoveredRowTerminally(row, reason)`, `failConsumedRowTerminally(row,
12
+ * reason)` and `noteRestartTurnOutcome(row, outcome)` are the runtime's own;
13
+ * `emit(event, payload)` is the socket broadcast.
14
+ */
15
+ export function createStaleTurnRecovery({
16
+ db,
17
+ stmts,
18
+ emit,
19
+ maxRequeueRetries,
20
+ failRecoveredRowTerminally,
21
+ failConsumedRowTerminally,
22
+ noteRestartTurnOutcome,
23
+ warn = (line) => console.warn(line),
24
+ } = {}) {
25
+ if (!db) throw new Error('createStaleTurnRecovery requires db');
26
+ if (!stmts) throw new Error('createStaleTurnRecovery requires stmts');
27
+ if (typeof emit !== 'function') throw new Error('createStaleTurnRecovery requires emit');
28
+
29
+ // A Stop still pending for a requeued turn is settled: the attempt it was
30
+ // aimed at is gone.
31
+ const settleRecoveredAbortControls = db.prepare(`
32
+ UPDATE relay_control_requests
33
+ SET status = 'failed',
34
+ error = ?,
35
+ updated_at = ?,
36
+ completed_at = ?
37
+ WHERE queue_message_id = ?
38
+ AND type IN ('abort_turn', 'abort_subagent')
39
+ AND status IN ('pending', 'processing')
40
+ `);
41
+
42
+ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso = null } = {}) {
43
+ const params = { inactiveBefore: cutoffIso, ceilingBefore: ceilingBeforeIso || null };
44
+ // Stale background-continuation turns are torn down, never requeued —
45
+ // replaying one would deliver the CLI's own continuation as a user prompt.
46
+ const staleContinuations = stmts.listStaleProcessingContinuations?.all?.(params) || [];
47
+ for (const row of staleContinuations) {
48
+ stmts.dropStaleContinuation?.run?.(row.id);
49
+ emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
50
+ noteRestartTurnOutcome(row, 'cut');
51
+ // A teardown is a turn ending with no reply; it must be visible in the
52
+ // console log (the only trace of a silent one was `ignored=already_failed`
53
+ // hours later, when the worker's reply arrived for the dead row).
54
+ const lastSeen = row.owner_last_claimed_at || row.processing_at || row.timestamp || null;
55
+ const reason = lastSeen && lastSeen < cutoffIso ? `inactive since ${lastSeen}` : `ceiling (started ${row.processing_at || row.timestamp})`;
56
+ warn(`STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
57
+ }
58
+ const listedRows = stmts.listRecoverableProcessing.all(params);
59
+ for (const row of listedRows.filter((entry) => entry?.consumed_at)) {
60
+ failConsumedRowTerminally(row, 'stale-recovery');
61
+ noteRestartTurnOutcome(row, 'cut');
62
+ }
63
+ const allRows = listedRows.filter((entry) => !entry?.consumed_at);
64
+ if (!allRows.length) return [];
65
+ // Enforce the retry budget before requeueing: failing these rows first
66
+ // flips them out of 'processing' so the recovery UPDATE below skips them.
67
+ const overBudget = allRows.filter((row) => Number(row?.retry_count || 0) + 1 >= maxRequeueRetries);
68
+ for (const row of overBudget) {
69
+ failRecoveredRowTerminally(row, 'stale-recovery');
70
+ noteRestartTurnOutcome(row, 'failed');
71
+ }
72
+ const dueRows = allRows.filter((row) => Number(row?.retry_count || 0) + 1 < maxRequeueRetries);
73
+ if (!dueRows.length) return [];
74
+
75
+ // Requeue exactly the listed rows, and announce only the ones the
76
+ // UPDATE took (its re-check of the rule is a safeguard; nothing runs
77
+ // between the listing and here).
78
+ const rows = [];
79
+ const tx = db.transaction(() => {
80
+ const now = new Date().toISOString();
81
+ for (const row of dueRows) {
82
+ if (stmts.recoverProcessingBefore.run({ ...params, id: row.id, requeueAt: requeueAtIso }).changes === 0) continue;
83
+ settleRecoveredAbortControls.run('queue-recovered', now, now, row.id);
84
+ rows.push(row);
85
+ }
86
+ });
87
+ tx();
88
+ if (!rows.length) return [];
89
+
90
+ for (const row of rows) {
91
+ emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
92
+ noteRestartTurnOutcome(row, 'requeued');
93
+ }
94
+ emit('queue_updated', { recovered: rows.length });
95
+ return rows;
96
+ }
97
+
98
+ return { recoverProcessingOlderThan };
99
+ }
@@ -140,7 +140,10 @@ export function createSubagentTimelineService({ db, stmts, now = () => new Date(
140
140
  if (hasFields && headerSupported()) {
141
141
  stmts.updateSubagentRunHeader.run({ id: runId, updatedAt: at, ...fields });
142
142
  }
143
- if (status) {
143
+ if (status === 'running' && typeof stmts.reopenSubagentRun?.run === 'function') {
144
+ // Running (again, for a resumed agent): the end of an earlier run goes.
145
+ stmts.reopenSubagentRun.run(at, runId);
146
+ } else if (status) {
144
147
  const terminal = status === 'completed' || status === 'failed' || status === 'cancelled';
145
148
  stmts.updateSubagentRunStatus?.run?.(status, at, terminal ? at : null, runId);
146
149
  }