@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
@@ -3,16 +3,19 @@
3
3
  import nodeFs from 'fs';
4
4
  import nodePath from 'path';
5
5
 
6
+ import { resolveDataDir } from './data-dir-resolution.mjs';
7
+
6
8
  // Resolve the on-disk folder the Cursor SDK integration keeps for one session,
7
9
  // so the file explorer's Session root works on `cursor`-provider conversations.
8
10
  //
9
11
  // The Cursor worker roots its per-session agent store at
10
12
  // `<storeDir>/<sdkSessionId>/` (see cursor-session-worker.mjs / the storePath
11
13
  // in cursor-sdk-adapter.mjs), where storeDir is `$CURSOR_AGENT_STORE_DIR` or
12
- // `<serverDir>/data/cursor-agents`. The directory is created on the session's
13
- // first turn, so a missing directory means "nothing to browse yet" rather than
14
- // an error — the resolver returns null and the Session button stays disabled
15
- // until the first turn lands.
14
+ // `cursor-agents` in the relay's data dir. The worker and this resolver both
15
+ // go through resolveCursorSessionStoreDir, so they agree. The directory is
16
+ // created on the session's first turn, so a missing directory means "nothing
17
+ // to browse yet" rather than an error — the resolver returns null and the
18
+ // Session button stays disabled until the first turn lands.
16
19
 
17
20
  const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
18
21
 
@@ -21,16 +24,74 @@ function normalizeText(value) {
21
24
  return text || '';
22
25
  }
23
26
 
27
+ /**
28
+ * `$CURSOR_AGENT_STORE_DIR`, else `cursor-agents` in the data dir the relay
29
+ * uses (resolveDataDir: env var, config `dataDir`, `<serverDir>/data`), which
30
+ * for a global install lies outside the package directory an update replaces.
31
+ */
24
32
  export function resolveCursorAgentStoreDir({
25
33
  env = process.env,
26
34
  serverDir = '',
35
+ configPath = '',
36
+ // The relay's data dir when the caller already has it resolved.
37
+ dataDir = '',
38
+ fs = nodeFs,
27
39
  path = nodePath,
28
40
  } = {}) {
29
41
  const override = normalizeText(env?.CURSOR_AGENT_STORE_DIR);
30
42
  if (override) return override;
31
43
  const base = normalizeText(serverDir);
32
44
  if (!base) return '';
33
- return path.join(base, 'data', 'cursor-agents');
45
+ const resolvedDataDir = normalizeText(dataDir)
46
+ || resolveDataDir({ env, configPath, serverDir: base, fsImpl: fs, pathImpl: path });
47
+ return path.join(resolvedDataDir, 'cursor-agents');
48
+ }
49
+
50
+ function isDirectory(fs, target) {
51
+ try {
52
+ return fs.statSync(target).isDirectory();
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function agentDbMtimeMs(fs, path, sessionDir) {
59
+ try {
60
+ return fs.statSync(path.join(sessionDir, 'agent.db')).mtimeMs || 0;
61
+ } catch {
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The store folder one session uses. Before the store followed the data dir
68
+ * it always lived in `<serverDir>/data/cursor-agents`, so a session can have
69
+ * its store there (a relay whose data dir lies elsewhere, such as a checkout
70
+ * pointed at other data; on a global install that folder went with the
71
+ * update). A session with a store only there keeps using it in place; with
72
+ * one in both places, the one whose agent.db changed last wins (the copy the
73
+ * state migration made dates from the migration). Nothing is moved or
74
+ * deleted.
75
+ */
76
+ export function resolveCursorSessionStoreDir({
77
+ env = process.env,
78
+ serverDir = '',
79
+ configPath = '',
80
+ dataDir = '',
81
+ sdkSessionId = '',
82
+ fs = nodeFs,
83
+ path = nodePath,
84
+ } = {}) {
85
+ const storeDir = resolveCursorAgentStoreDir({ env, serverDir, configPath, dataDir, fs, path });
86
+ const sessionId = normalizeText(sdkSessionId);
87
+ if (!storeDir || !sessionId || normalizeText(env?.CURSOR_AGENT_STORE_DIR)) return storeDir;
88
+ const earlierDir = path.join(normalizeText(serverDir), 'data', 'cursor-agents');
89
+ if (path.resolve(earlierDir) === path.resolve(storeDir)) return storeDir;
90
+ const current = path.join(storeDir, sessionId);
91
+ const earlier = path.join(earlierDir, sessionId);
92
+ if (!isDirectory(fs, earlier)) return storeDir;
93
+ if (!isDirectory(fs, current)) return earlierDir;
94
+ return agentDbMtimeMs(fs, path, earlier) > agentDbMtimeMs(fs, path, current) ? earlierDir : storeDir;
34
95
  }
35
96
 
36
97
  export function createCursorSessionRootResolver({
@@ -38,6 +99,9 @@ export function createCursorSessionRootResolver({
38
99
  path = nodePath,
39
100
  env = process.env,
40
101
  serverDir = '',
102
+ configPath = '',
103
+ // The relay passes its resolved data dir, so no request reads config.json.
104
+ dataDir = '',
41
105
  } = {}) {
42
106
  /**
43
107
  * @returns {{ sessionRootPath: string, sessionRootName: string } | null}
@@ -47,7 +111,7 @@ export function createCursorSessionRootResolver({
47
111
  // The id is joined into a filesystem path, so it is validated before any
48
112
  // syscall (mirrors the Claude session-root resolver).
49
113
  if (!SESSION_ID_PATTERN.test(sessionId)) return null;
50
- const storeDir = resolveCursorAgentStoreDir({ env, serverDir, path });
114
+ const storeDir = resolveCursorSessionStoreDir({ env, serverDir, configPath, dataDir, sdkSessionId: sessionId, fs, path });
51
115
  if (!storeDir) return null;
52
116
  const sessionRootPath = path.join(storeDir, sessionId);
53
117
  try {
@@ -1,18 +1,21 @@
1
+ import crypto from 'crypto';
1
2
  import fs from 'fs';
2
3
  import path from 'path';
3
4
 
5
+ import { RELAY_STOP_EXIT_CODE } from '../relay-exit-codes.mjs';
6
+
4
7
  /**
5
8
  * Where the relay's database (and lock) live, decided before anything else
6
9
  * touches disk.
7
10
  *
8
- * Precedence: the COPILOT_WEB_RELAY_DATA_DIR env var (launchers and the e2e
9
- * harness pin it), then a `dataDir` key in config.json, then the server
10
- * directory's own `data/`. The config key exists so an instance's identity —
11
- * including where its data lives — travels entirely inside config.json: a
12
- * dev checkout can point at a release install's data dir (the singleton
13
- * guard's lock lives inside the data dir, so two relays sharing one refuse
14
- * to run concurrently), and a regenerated autostart launcher only has to pin
15
- * the config path to reproduce the whole instance.
11
+ * Precedence: the COPILOT_WEB_RELAY_DATA_DIR env var, then a `dataDir` key in
12
+ * config.json, then the server directory's own `data/`. Every launcher of a
13
+ * global install pins the env var (`oar start`, `oar copilot`, the systemd
14
+ * unit, both Windows autostart launchers; see resolveRelayStateDirs in
15
+ * oar-state-layout.mjs), and so does the e2e harness. Nothing writes the
16
+ * config key: it is for a hand-edited config, so a dev checkout can point at
17
+ * a release install's data dir (the singleton guard's lock lives inside the
18
+ * data dir, so two relays sharing one refuse to run concurrently).
16
19
  *
17
20
  * A relative `dataDir` resolves against the config file's directory, not the
18
21
  * process cwd — the same config must mean the same data dir no matter where
@@ -27,21 +30,207 @@ export function resolveDataDir({
27
30
  } = {}) {
28
31
  const fromEnv = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
29
32
  if (fromEnv) return pathImpl.resolve(fromEnv);
33
+ return readConfiguredDataDir({ configPath, fsImpl, pathImpl }) || pathImpl.join(String(serverDir || ''), 'data');
34
+ }
30
35
 
36
+ /**
37
+ * The `dataDir` config.json names, resolved against the config's folder; ''
38
+ * when it names none. An unreadable, absent or invalid config names none: the
39
+ * runtime reports config problems itself when it loads the file.
40
+ */
41
+ export function readConfiguredDataDir({ configPath, fsImpl = fs, pathImpl = path } = {}) {
31
42
  const resolvedConfigPath = String(configPath || '').trim();
32
- if (resolvedConfigPath) {
33
- let parsed = null;
43
+ if (!resolvedConfigPath) return '';
44
+ let parsed = null;
45
+ try { parsed = JSON.parse(fsImpl.readFileSync(resolvedConfigPath, 'utf8')); } catch {}
46
+ const fromConfig = String(parsed?.dataDir || '').trim();
47
+ return fromConfig ? pathImpl.resolve(pathImpl.dirname(resolvedConfigPath), fromConfig) : '';
48
+ }
49
+
50
+ /**
51
+ * Where uploads live, given the data dir resolveDataDir chose: `uploads/`
52
+ * inside it whenever the env var or config.json picked it, so `dataDir` moves
53
+ * database and uploads together. Only the plain default keeps the checkout
54
+ * layout, `<server>/uploads` beside `<server>/data`. Uploads earlier versions
55
+ * left elsewhere are still read (resolveUploadReadDirs).
56
+ */
57
+ export function resolveUploadDir({
58
+ dataDir,
59
+ env = process.env,
60
+ serverDir,
61
+ pathImpl = path,
62
+ platform = process.platform,
63
+ } = {}) {
64
+ const pinned = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
65
+ const normalize = (value) => {
66
+ const resolved = pathImpl.resolve(String(value || ''));
67
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
68
+ };
69
+ if (!pinned && normalize(dataDir) === normalize(pathImpl.join(String(serverDir || ''), 'data'))) {
70
+ return pathImpl.join(String(serverDir || ''), 'uploads');
71
+ }
72
+ return pathImpl.join(String(dataDir || ''), 'uploads');
73
+ }
74
+
75
+ /**
76
+ * Creates the data and upload folders at startup. One that cannot be created
77
+ * (its drive is missing: a RAM disk or a drive a startup script mounts later
78
+ * than the autostart launcher waited; a file in its place; no permission)
79
+ * ends this runtime with a line naming it and RELAY_STOP_EXIT_CODE instead
80
+ * of an uncaught error: the supervisors do not retry that code, so the line
81
+ * is said once, and the code goes on to whatever started the relay. No
82
+ * other folder is tried: the data must never go elsewhere unnoticed. False
83
+ * when it called `exit`.
84
+ */
85
+ export function ensureDataDirs({
86
+ dataDir,
87
+ uploadDir,
88
+ fsImpl = fs,
89
+ logger = console,
90
+ exit = (code) => process.exit(code),
91
+ } = {}) {
92
+ const fail = (line) => {
93
+ logger.error(`[startup] ${line}. Exiting with code ${RELAY_STOP_EXIT_CODE}.`);
94
+ exit(RELAY_STOP_EXIT_CODE);
95
+ return false;
96
+ };
97
+ for (const [dir, options] of [[dataDir, { recursive: true, mode: 0o700 }], [uploadDir, { recursive: true }]]) {
34
98
  try {
35
- parsed = JSON.parse(fsImpl.readFileSync(resolvedConfigPath, 'utf8'));
36
- } catch {
37
- // Unreadable/absent/invalid config falls through to the default; the
38
- // runtime reports config problems itself when it loads the file.
39
- }
40
- const fromConfig = String(parsed?.dataDir || '').trim();
41
- if (fromConfig) {
42
- return pathImpl.resolve(pathImpl.dirname(resolvedConfigPath), fromConfig);
99
+ let stat = null;
100
+ try {
101
+ stat = fsImpl.statSync(dir);
102
+ } catch (error) {
103
+ if (error?.code !== 'ENOENT') throw error;
104
+ }
105
+ if (stat && !stat.isDirectory()) return fail(`${dir} is a file, not a folder`);
106
+ if (!stat) fsImpl.mkdirSync(dir, options);
107
+ } catch (error) {
108
+ if (error?.code === 'ENOTDIR') return fail(`Cannot create ${dir}: it or a folder above it is a file, not a folder`);
109
+ const reason = error?.code === 'ENOENT' ? 'its drive or a folder above it is missing' : (error?.message || String(error));
110
+ return fail(`Cannot create ${dir}: ${reason}`);
43
111
  }
44
112
  }
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * The folders uploads are read from, the upload dir first. Earlier versions
118
+ * left uploads in two other places, and both stay folders to read from so no
119
+ * upload drops out of sight after an update: `<server>/uploads` (before 0.9.14
120
+ * a config `dataDir` did not move uploads) and `uploads/` in the folder that
121
+ * holds the data dir (where state migrations before 0.9.14 copied a
122
+ * checkout's uploads). Nothing is copied, moved or deleted there; new uploads
123
+ * go to the upload dir only.
124
+ */
125
+ export function resolveUploadReadDirs({
126
+ uploadDir,
127
+ dataDir,
128
+ serverDir,
129
+ pathImpl = path,
130
+ platform = process.platform,
131
+ } = {}) {
132
+ const normalize = (value) => {
133
+ const resolved = pathImpl.resolve(String(value || ''));
134
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
135
+ };
136
+ const dirs = [];
137
+ const add = (dir) => {
138
+ if (!String(dir || '').trim()) return;
139
+ if (dirs.some((known) => normalize(known) === normalize(dir))) return;
140
+ dirs.push(pathImpl.resolve(dir));
141
+ };
142
+ add(uploadDir);
143
+ if (String(serverDir || '').trim()) add(pathImpl.join(String(serverDir), 'uploads'));
144
+ if (String(dataDir || '').trim()) add(pathImpl.join(pathImpl.dirname(pathImpl.resolve(String(dataDir))), 'uploads'));
145
+ return dirs;
146
+ }
147
+
148
+ const SHA256_NAME = /^[a-f0-9]{64}$/;
149
+ const SAMPLE_BYTES = 64 * 1024;
150
+ const MAX_REMEMBERED_CHOICES = 10_000;
151
+ // Which of several same-size copies to read, per sha256, with the stat of
152
+ // every copy it was decided on: decided once, again only when a copy changed.
153
+ const rememberedChoices = new Map();
154
+
155
+ /**
156
+ * Every existing copy of an upload blob in the read dirs, upload dir first.
157
+ * Only a sha256 name is looked up: nothing else in those folders is a blob.
158
+ */
159
+ export function existingUploadPaths(dirs, sha256, { fsImpl = fs, pathImpl = path } = {}) {
160
+ const name = String(sha256 || '').trim().toLowerCase();
161
+ if (!SHA256_NAME.test(name)) return [];
162
+ const found = [];
163
+ for (const dir of dirs || []) {
164
+ const candidate = pathImpl.join(dir, name);
165
+ try {
166
+ const stat = fsImpl.statSync(candidate);
167
+ if (stat.isFile()) found.push({ path: candidate, size: stat.size, mtimeMs: stat.mtimeMs });
168
+ } catch {}
169
+ }
170
+ return found;
171
+ }
45
172
 
46
- return pathImpl.join(String(serverDir || ''), 'data');
173
+ // The first and last bytes of a file: copies that share them are taken as the
174
+ // same file without reading them whole.
175
+ function sampleOf(filePath, size, fsImpl) {
176
+ let fd = null;
177
+ try {
178
+ fd = fsImpl.openSync(filePath, 'r');
179
+ const head = Buffer.alloc(Math.min(SAMPLE_BYTES, size));
180
+ fsImpl.readSync(fd, head, 0, head.length, 0);
181
+ const tailLength = Math.min(SAMPLE_BYTES, Math.max(0, size - head.length));
182
+ const tail = Buffer.alloc(tailLength);
183
+ if (tailLength) fsImpl.readSync(fd, tail, 0, tailLength, size - tailLength);
184
+ return Buffer.concat([head, tail]);
185
+ } catch {
186
+ return null;
187
+ } finally {
188
+ if (fd !== null) try { fsImpl.closeSync(fd); } catch {}
189
+ }
190
+ }
191
+
192
+ function contentMatches(filePath, sha256, fsImpl) {
193
+ try {
194
+ return crypto.createHash('sha256').update(fsImpl.readFileSync(filePath)).digest('hex') === sha256;
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ function chooseAmongSameSize(copies, sha256, fsImpl) {
201
+ const signature = copies.map((copy) => `${copy.path}|${copy.size}|${copy.mtimeMs}`).join('\n');
202
+ const remembered = rememberedChoices.get(sha256);
203
+ if (remembered && remembered.signature === signature) return remembered.path;
204
+ let chosen = copies[0].path;
205
+ const first = sampleOf(copies[0].path, copies[0].size, fsImpl);
206
+ const alike = first && copies.slice(1).every((copy) => {
207
+ const sample = sampleOf(copy.path, copy.size, fsImpl);
208
+ return sample && sample.equals(first);
209
+ });
210
+ if (!alike) {
211
+ const verified = copies.find((copy) => contentMatches(copy.path, sha256, fsImpl));
212
+ if (verified) chosen = verified.path;
213
+ }
214
+ if (rememberedChoices.size >= MAX_REMEMBERED_CHOICES) rememberedChoices.delete(rememberedChoices.keys().next().value);
215
+ rememberedChoices.set(sha256, { signature, path: chosen });
216
+ return chosen;
217
+ }
218
+
219
+ /**
220
+ * The path an upload is read from. Blobs are named by the sha256 of their
221
+ * content, so copies in two folders are normally the same file. Should they
222
+ * differ, the copy of the recorded size wins (`expectedSize`); if more than
223
+ * one has that size and they are not alike, the one whose content hashes to
224
+ * its name (decided once per blob until a copy changes). No copy is ever
225
+ * touched. Without any copy: its place in the upload dir, where a new upload
226
+ * is written.
227
+ */
228
+ export function uploadPathInDirs(dirs, sha256, { expectedSize = null, fsImpl = fs, pathImpl = path } = {}) {
229
+ const name = String(sha256 || '').trim().toLowerCase();
230
+ const copies = existingUploadPaths(dirs, name, { fsImpl, pathImpl });
231
+ if (!copies.length) return pathImpl.join(dirs[0], name);
232
+ const size = Number(expectedSize);
233
+ const sized = Number.isFinite(size) && size > 0 ? copies.filter((copy) => copy.size === size) : [];
234
+ if (sized.length > 1) return chooseAmongSameSize(sized, name, fsImpl);
235
+ return (sized[0] || copies[0]).path;
47
236
  }