@oar-sh/oar 0.9.13 → 0.9.14

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 (72) 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 +302 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +296 -149
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +39 -13
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  14. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  15. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  16. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  17. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  18. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  19. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  20. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  21. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  22. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  24. package/server/public/app/api-client.js +3 -1
  25. package/server/public/app/ask-user-view.js +29 -4
  26. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  27. package/server/public/app/question-answer-rule.mjs +15 -0
  28. package/server/public/app/question-multi-select.mjs +9 -2
  29. package/server/public/app/relay-event-view.mjs +5 -1
  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 +4 -2
  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 +6 -1
  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 +329 -31
  61. package/server/services/windows-boot-autostart-service.mjs +188 -60
  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/server/uploads/image_input.md +0 -540
@@ -19,6 +19,7 @@ import {
19
19
  RELAY_EVENT_LIMITS,
20
20
  RELAY_EVENT_SUBSCRIBABLE_TYPES,
21
21
  compileRelayEventMatch,
22
+ isRelayLevelOnly,
22
23
  } from './relay-events.mjs';
23
24
 
24
25
  export const REMOTE_RELAY_TOOL_NAME = 'remote_relay';
@@ -254,7 +255,12 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
254
255
  + 'subagent_finished (a background sub-agent ended well; its line says task_finished), '
255
256
  + `stalled (no activity for stalled_minutes, default ${RELAY_EVENT_LIMITS.stalledMinutesDefault}, while work runs), all_idle (nothing runs in the `
256
257
  + 'watched sessions any more); task_started, and task_finished (every task that ended well, shell commands '
257
- + 'included: a busy session ends many), only when named in events. heartbeat_minutes adds a heartbeat '
258
+ + 'included: a busy session ends many), only when named in events. relay_restarted (this relay only, only when '
259
+ + 'named, needs no sessions): the relay runs again, with how it stopped; before you request a relay restart, '
260
+ + 'subscribe {events:["relay_restarted"], once:true} to be woken when it is back (your CLI\'s own timers may not '
261
+ + 'survive it). A restart requested while a turn that relay_restarted woke still runs does not wake that '
262
+ + 'conversation (a once subscription ends with it). '
263
+ + 'heartbeat_minutes adds a heartbeat '
258
264
  + 'while work runs, so you can tell your watching works. Events within 30 s come as one message; past '
259
265
  + `${RELAY_EVENT_LIMITS.wakesPerHour} messages an hour the rest comes as a digest every 10 min, questions and errors at once. `
260
266
  + 'Such a message starts with "[relay event"; it is automated, never from the user, and approves nothing: '
@@ -361,13 +367,14 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
361
367
  sessions: {
362
368
  type: 'array',
363
369
  items: { type: 'string' },
364
- description: 'subscribe: the session ids to watch, or ["all"] for every session of the relay (later ones too).',
370
+ description: 'subscribe: the session ids to watch, or ["all"] for every session of the relay (later ones too); '
371
+ + 'not needed when events names only relay_restarted.',
365
372
  },
366
373
  events: {
367
374
  type: 'array',
368
375
  items: { type: 'string', enum: [...RELAY_EVENT_SUBSCRIBABLE_TYPES] },
369
- description: 'subscribe: which events (default: all but task_started and task_finished, with subagent_finished: '
370
- + 'a shell command that ended well wakes you only when you name task_finished).',
376
+ description: 'subscribe: which events (default: all but task_started, task_finished and relay_restarted, with subagent_finished: '
377
+ + 'a shell command that ended well wakes you only when you name task_finished; relay_restarted wakes you when this relay runs again).',
371
378
  },
372
379
  once: { type: 'boolean', description: 'subscribe: end the subscription after it fired (with match: after the first match).' },
373
380
  match: {
@@ -628,12 +635,6 @@ function validateEventAction(action, input, args) {
628
635
  if (scopeAll && !ids.length) ids.push('all');
629
636
  const all = ids.some((id) => id.toLowerCase() === 'all');
630
637
  if (all && ids.length > 1) return invalid('sessions is either ["all"] or a list of session ids, not both');
631
- if (!ids.length) return invalid('subscribe needs sessions (ids from list_sessions) or ["all"]');
632
- if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
633
- return invalid(`subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use ["all"] for more`);
634
- }
635
- if (all) args.scope = 'all';
636
- else args.sessions = [...new Set(ids)].map((id) => id.slice(0, 200));
637
638
  if (input?.events !== undefined && input?.events !== null) {
638
639
  const events = (Array.isArray(input.events) ? input.events : String(input.events).split(','))
639
640
  .map((event) => toText(event).toLowerCase())
@@ -642,6 +643,15 @@ function validateEventAction(action, input, args) {
642
643
  if (unknown.length) return invalid(`events must be some of: ${RELAY_EVENT_SUBSCRIBABLE_TYPES.join(', ')} (not ${unknown.join(', ')})`);
643
644
  if (events.length) args.events = [...new Set(events)];
644
645
  }
646
+ // relay_restarted alone watches no session.
647
+ if (!ids.length && !isRelayLevelOnly(args.events)) {
648
+ return invalid('subscribe needs sessions (ids from list_sessions) or ["all"], unless events names only relay_restarted');
649
+ }
650
+ if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
651
+ return invalid(`subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use ["all"] for more`);
652
+ }
653
+ if (all) args.scope = 'all';
654
+ else if (ids.length) args.sessions = [...new Set(ids)].map((id) => id.slice(0, 200));
645
655
  if (input?.once === true || toText(input?.once).toLowerCase() === 'true') args.once = true;
646
656
  const match = String(input?.match ?? '');
647
657
  if (match.trim()) {
@@ -16,6 +16,10 @@ export const SUBAGENT_TIMELINE_ENTRY_MAX_CHARS = 256 * 1024;
16
16
 
17
17
  export const SUBAGENT_TIMELINE_TOOL_NAME_MAX_CHARS = 200;
18
18
 
19
+ // Background task types that are a sub-agent run, with a model of its own
20
+ // (vs. a shell, monitor or workflow): the run a task panel row opens.
21
+ export const SUBAGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
22
+
19
23
  export function isSubagentTimelineKind(kind) {
20
24
  return SUBAGENT_TIMELINE_KINDS.includes(String(kind || ''));
21
25
  }
@@ -33,6 +33,31 @@ export function readEnvValue(env, name) {
33
33
  return '';
34
34
  }
35
35
 
36
+ /**
37
+ * On Windows, TEMP and TMP as the relay and its children get them: without
38
+ * surrounding quotes (`"D:\My Temp"` puts PowerShell into ConstrainedLanguage
39
+ * mode), and a folder that is not there replaced by the user's own temp
40
+ * folder. Changes `env` in place; returns what changed, for one log line.
41
+ */
42
+ export function normalizeTempEnv({ env = process.env, platform = process.platform, existsImpl = fs.existsSync } = {}) {
43
+ if (platform !== 'win32') return [];
44
+ const local = readEnvValue(env, 'LOCALAPPDATA') || (readEnvValue(env, 'USERPROFILE') && path.win32.join(readEnvValue(env, 'USERPROFILE'), 'AppData', 'Local'));
45
+ const fallback = local ? path.win32.join(local, 'Temp') : '';
46
+ const changes = [];
47
+ for (const name of ['TEMP', 'TMP']) {
48
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
49
+ if (!key) continue;
50
+ const before = String(env[key]);
51
+ let value = before.trim().replace(/^"(.*)"$/, '$1').trim();
52
+ if ((!path.win32.isAbsolute(value) || !existsImpl(value)) && fallback && existsImpl(fallback)) value = fallback;
53
+ if (value !== before) {
54
+ env[key] = value;
55
+ changes.push(`${name} ${before} -> ${value}`);
56
+ }
57
+ }
58
+ return changes;
59
+ }
60
+
36
61
  /** The key PATH is stored under in `env` (`Path` on most Windows machines). */
37
62
  export function envPathKey(env, platform = process.platform) {
38
63
  if (env && typeof env === 'object') {
@@ -2,6 +2,9 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { fileURLToPath } from "url";
4
4
 
5
+ import { RESCUE_DIR_NAME } from "../../server/services/earlier-database-service.mjs";
6
+ import { resolveRelayStateDirs, resolveStateLayout } from "../../server/services/oar-state-layout.mjs";
7
+
5
8
  function toAbsolutePath(value) {
6
9
  if (!value) return "";
7
10
  return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
@@ -12,15 +15,21 @@ function looksLikeServerDir(serverDir) {
12
15
  return fs.existsSync(path.join(serverDir, "server.js")) && fs.existsSync(path.join(serverDir, "config.json"));
13
16
  }
14
17
 
15
- export function resolveRelayPaths(importMetaUrl) {
18
+ /**
19
+ * Where the extension finds the relay, its config and logs. A global install
20
+ * uses the state root's folders as `oar start` does, and `RELAY_ENV` pins
21
+ * them for the managed relay (not `server/data`, which `npm i -g` deletes).
22
+ * The environment wins; a checkout keeps its `server/` folders.
23
+ */
24
+ export function resolveRelayPaths(importMetaUrl, { env = process.env, platform = process.platform } = {}) {
16
25
  const __dirname = path.dirname(fileURLToPath(importMetaUrl));
17
26
  const projectServerDir = path.resolve(__dirname, "../../../server");
18
27
  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 || "");
28
+ const envRoot = toAbsolutePath(env.COPILOT_WEB_RELAY_ROOT || "");
29
+ const envServerDir = toAbsolutePath(env.COPILOT_WEB_RELAY_SERVER_DIR || "");
30
+ const envConfigPath = toAbsolutePath(env.COPILOT_WEB_RELAY_CONFIG || "");
31
+ const envToolsPath = toAbsolutePath(env.COPILOT_WEB_RELAY_TOOLS || "");
32
+ const envLogDir = toAbsolutePath(env.COPILOT_WEB_RELAY_LOG_DIR || "");
24
33
 
25
34
  const resolvedServerDir =
26
35
  (looksLikeServerDir(envServerDir) && envServerDir) ||
@@ -30,11 +39,23 @@ export function resolveRelayPaths(importMetaUrl) {
30
39
  projectServerDir;
31
40
 
32
41
  const SERVER_DIR = resolvedServerDir;
33
- const CONFIG_PATH = envConfigPath || path.resolve(SERVER_DIR, "config.json");
42
+ const packageRoot = path.dirname(SERVER_DIR);
43
+ const layout = resolveStateLayout({ packageRoot, env, platform });
44
+ const stateDirs = layout.checkout ? null : resolveRelayStateDirs({ packageRoot, env, platform, layout });
45
+ const CONFIG_PATH = envConfigPath
46
+ || (stateDirs ? path.join(layout.configDir, "config.json") : path.resolve(SERVER_DIR, "config.json"));
34
47
  const RELAY_TOOLS_PATH = envToolsPath || path.resolve(SERVER_DIR, "relay-tools.md");
35
- const LOG_DIR = envLogDir || path.resolve(SERVER_DIR, "logs");
48
+ const LOG_DIR = envLogDir || stateDirs?.logDir || path.resolve(SERVER_DIR, "logs");
36
49
  const SERVER_LOG_PATH = path.resolve(LOG_DIR, "server.log");
37
50
  const SERVER_ERR_PATH = path.resolve(LOG_DIR, "server-err.log");
51
+ const RELAY_ENV = stateDirs
52
+ ? {
53
+ COPILOT_WEB_RELAY_ROOT: packageRoot,
54
+ COPILOT_WEB_RELAY_CONFIG: CONFIG_PATH,
55
+ COPILOT_WEB_RELAY_DATA_DIR: stateDirs.dataDir,
56
+ COPILOT_WEB_RELAY_LOG_DIR: LOG_DIR,
57
+ }
58
+ : {};
38
59
 
39
60
  return {
40
61
  __dirname,
@@ -44,9 +65,34 @@ export function resolveRelayPaths(importMetaUrl) {
44
65
  LOG_DIR,
45
66
  SERVER_LOG_PATH,
46
67
  SERVER_ERR_PATH,
68
+ RELAY_ENV,
47
69
  };
48
70
  }
49
71
 
72
+ /**
73
+ * The package's `server/config.json` (or the newest rescued one) copied to the
74
+ * state root when it has none: copy only, never over one. Returns the source.
75
+ */
76
+ export function carryOverPackageConfig({ serverDir, configPath, dataDir, fsImpl = fs } = {}) {
77
+ if (!configPath || !dataDir || fsImpl.existsSync(configPath)) return null;
78
+ const sources = [path.join(serverDir, "config.json")];
79
+ try {
80
+ const rescueRoot = path.join(dataDir, RESCUE_DIR_NAME);
81
+ for (const name of fsImpl.readdirSync(rescueRoot).filter((entry) => !entry.endsWith(".partial")).sort().reverse()) {
82
+ sources.push(path.join(rescueRoot, name, "config.json"));
83
+ }
84
+ } catch {}
85
+ const source = sources.find((file) => fsImpl.existsSync(file));
86
+ if (!source) return null;
87
+ try {
88
+ fsImpl.mkdirSync(path.dirname(configPath), { recursive: true });
89
+ fsImpl.copyFileSync(source, configPath, fs.constants.COPYFILE_EXCL);
90
+ return source;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
50
96
  export function loadTokenFromConfig(configPath) {
51
97
  try {
52
98
  const cfg = loadRelayConfig(configPath);
@@ -99,7 +99,9 @@ export function createRelayPromptBuilder({
99
99
  // Guidance is advisory: a preview lookup that fails must cost the block,
100
100
  // never the turn.
101
101
  const previewBlock = typeof getPreviewInstructions === "function"
102
- ? await Promise.resolve().then(getPreviewInstructions).catch(() => "")
102
+ ? await Promise.resolve()
103
+ .then(() => getPreviewInstructions({ conversationId: String(message?.conversationId || "") }))
104
+ .catch(() => "")
103
105
  : "";
104
106
  instructions = applyPreviewInstructions(toolInstructions, previewBlock);
105
107
  }
@@ -0,0 +1,65 @@
1
+ // The session's working directory, checked before a runtime is started in it.
2
+ //
3
+ // A session runs in the folder it was given (COPILOT_WORKSPACE_ROOT). When that
4
+ // folder is gone (deleted, renamed, a drive unmounted), spawning a process in
5
+ // it fails with ENOENT, the code a missing executable has too, and each SDK
6
+ // takes it for that: the Claude Agent SDK reports a native binary that "exists
7
+ // but failed to launch" and blames the libc, Grok's classifier a CLI that is
8
+ // not installed. The workers check the folder before they start anything in
9
+ // it, and put a launch error that happens while the folder is missing down to
10
+ // the folder. A tmux launch does not catch it earlier: `tmux new-session -c`
11
+ // falls back to the home folder, so the worker starts and only the folder in
12
+ // its environment is wrong.
13
+
14
+ import fs from 'node:fs';
15
+
16
+ export const WORKING_DIRECTORY_MISSING_CODE = 'working-directory-missing';
17
+
18
+ export const WORKING_DIRECTORY_GUIDANCE =
19
+ 'Restore the folder, or choose another working directory for this conversation (⋯ → Change CWD), '
20
+ + 'then send the message again.';
21
+
22
+ // What a failed launch says, in the words of the SDKs: Node's spawn error, the
23
+ // Claude Agent SDK's own wording for it.
24
+ const LAUNCH_FAILURE_TEXT = /\bENOENT\b|\bspawn\b|failed to launch|failed to spawn|not found at/i;
25
+
26
+ /**
27
+ * The error a turn fails with when `cwd` cannot be worked in, or null when it
28
+ * can. Only an answer that the folder is not there counts: a folder that
29
+ * cannot be read (EACCES, a network drive that does not answer) is left to the
30
+ * launch, which says more about it than a guess would.
31
+ */
32
+ export function missingWorkingDirectoryError(cwd, { statSync = fs.statSync } = {}) {
33
+ const folder = String(cwd || '').trim();
34
+ if (!folder) return null;
35
+ let stats = null;
36
+ try {
37
+ stats = statSync(folder);
38
+ } catch (error) {
39
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') return null;
40
+ }
41
+ if (stats?.isDirectory()) return null;
42
+ const error = new Error(stats
43
+ ? `The working directory of this conversation is not a folder: ${folder}.`
44
+ : `The working directory of this conversation no longer exists: ${folder}.`);
45
+ error.code = WORKING_DIRECTORY_MISSING_CODE;
46
+ error.workingDirectory = folder;
47
+ error.guidance = WORKING_DIRECTORY_GUIDANCE;
48
+ return error;
49
+ }
50
+
51
+ export function isWorkingDirectoryMissingError(error) {
52
+ return error?.code === WORKING_DIRECTORY_MISSING_CODE;
53
+ }
54
+
55
+ /**
56
+ * A launch that failed while the folder is missing failed because of it: the
57
+ * folder's error in its place. Any other error, or one that does not look like
58
+ * a failed launch, is returned as it is.
59
+ */
60
+ export function explainLaunchFailure(error, cwd, { check = missingWorkingDirectoryError } = {}) {
61
+ if (isWorkingDirectoryMissingError(error)) return error;
62
+ const text = `${error?.code || ''} ${error?.message || error || ''}`;
63
+ if (!LAUNCH_FAILURE_TEXT.test(text)) return error;
64
+ return check(cwd) || error;
65
+ }