@oar-sh/oar 0.9.22 → 0.9.23

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,99 @@
3
3
  All notable changes to OAR are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [0.9.23] — 2026-11-07
7
+
8
+ Highlights: a dependency round. The Copilot, Claude and Cursor SDKs, zod,
9
+ express, socket.io, ws and better-sqlite3 move to newer releases:
10
+ the tree holds one zod, without CVE-2023-54404, and one express, and
11
+ prebuild-install, which is no longer maintained, is gone. OAR's package has
12
+ no install script any more, which package scanners flag. On Termux, 32-bit
13
+ ARM and FreeBSD, run **`oar build-native`** once after updating to this
14
+ version: it builds the SQLite binding, and later updates build it
15
+ themselves.
16
+
17
+ ### Added
18
+
19
+ - **`oar build-native`.** better-sqlite3 13 ships its SQLite binding for
20
+ Linux, macOS and Windows on x64 and arm64. On Android under Termux, 32-bit
21
+ ARM and FreeBSD, `oar build-native` compiles it in the installed package
22
+ (Python 3, make and a C++ compiler; on Termux `pkg install python clang
23
+ make`). The **Update** button and `oar update` run it there themselves
24
+ before the relay restarts; a build that fails ends the update in an error
25
+ and leaves the running relay alone. Elsewhere it has nothing to do, so an
26
+ installer may run it everywhere. A relay started without the binding says
27
+ which command to run and exits instead of printing a stack trace.
28
+ - **What the agent SDKs contact.** docs/providers.md lists the services the
29
+ Copilot, Claude and Cursor SDKs and their CLIs talk to, and what the
30
+ Statsig client inside the Cursor SDK does.
31
+
32
+ ### Changed
33
+
34
+ - **No install script.** OAR's package has no `preinstall` any more. From
35
+ 0.9.14 to 0.9.22 it copied the data a relay before 0.9.14 kept inside the
36
+ package out before npm deleted it. npm 12 never ran it without
37
+ `--allow-scripts`, and package scanners flag every package that has one.
38
+ A copy those versions made is still taken over at the next start.
39
+ - **`--allow-scripts=koffi`.** better-sqlite3 13 has no install script, so
40
+ a manual install with npm 12 is now
41
+ `npm install -g --allow-scripts=koffi @oar-sh/oar`; the installers, the
42
+ **Update** button and `oar update` pass it themselves.
43
+ - **No terminal console.** The optional `tty-console` dependency is gone,
44
+ and neo-blessed with it. A relay started in a terminal prints its log
45
+ lines, each with its time, as under a service; routine worker lines are
46
+ no longer held back.
47
+ - **Express 5.** The file routes take express 5's syntax and serve the
48
+ same paths. Static JavaScript is served as `text/javascript`.
49
+ - `better-sqlite3` 13.0.3 (was 12.11.1); `prebuild-install`, which is no
50
+ longer maintained, and its subtree are gone. New in the tree:
51
+ `node-addon-api` 8.9.2, the headers of the source build.
52
+ - `express` 5.3.0 (was 4.22.3), the major the MCP SDK already used: one
53
+ express instead of two, and array-flatten, mime 1 and the rest of express
54
+ 4's subtree are gone.
55
+ - `@anthropic-ai/claude-agent-sdk` 0.3.319 (was 0.3.304; bundled Claude
56
+ Code 2.1.319). The key chats (DeepSeek, Claude API) also pin the
57
+ cloud-sessions token and the two new switches that would let a folder's
58
+ settings widen which certificates Claude Code trusts.
59
+ - `@cursor/sdk` 1.3.1 (was 1.0.38). Its context-compaction summaries are
60
+ not shown yet.
61
+ - `@github/copilot-sdk` 1.0.29 (was 1.0.23; bundled Copilot CLI 1.0.107)
62
+ and `zod` 4.6.7 (was 4.4.3): one zod in the tree, without
63
+ CVE-2023-54404 (GHSA-mrhx-c7xc-m3w5).
64
+ - `socket.io` 4.8.4 (was 4.8.3) and `ws` 8.22.1 (was 8.21.3). `uuid` is
65
+ gone: Node's `crypto.randomUUID()` makes the same ids.
66
+ - package.json names its author and its bug tracker.
67
+
68
+ ### Upgrade notes
69
+
70
+ - **From 0.9.13 or older, copy the data out of the package first.** A relay
71
+ before 0.9.14 could keep its database, uploads and config inside the
72
+ package folder, which `npm install -g` deletes, and this version no longer
73
+ copies them out. Copy `data/`, `uploads/` and `config.json` from the
74
+ installed package's `server` folder into a new folder under
75
+ `rescued-package-data` in the data folder (for example
76
+ `~/.oar/data/rescued-package-data/before-update/`); the first start takes
77
+ them over. From 0.9.14 on there is nothing to do.
78
+ - **Termux, 32-bit ARM, FreeBSD: the update from 0.9.22 needs
79
+ `oar build-native` once.** The Update button and `oar update` of 0.9.22
80
+ install 0.9.23 but do not build the binding, so the relay stops at its
81
+ first start on 0.9.23: its log names the command, and the page cannot
82
+ reach the relay. In Termux run:
83
+
84
+ ```sh
85
+ pkg install python clang make
86
+ oar build-native
87
+ oar start
88
+ ```
89
+
90
+ Later updates build the binding themselves, before the relay restarts.
91
+ - **Linux needs glibc 2.34 or newer,** for example Debian 12, Ubuntu 22.04
92
+ or RHEL 9. The SQLite binding better-sqlite3 13 ships for Linux on x64
93
+ and arm64 needs it, and a binding built with `oar build-native` would not
94
+ be used there. On an older system a relay that updates to 0.9.23 stays
95
+ down at its first start and says to update the operating system; stay on
96
+ 0.9.22 until the system is updated.
97
+ - **No migration.** Going back to 0.9.22 needs no database step.
98
+
6
99
  ## [0.9.22] — 2026-11-05
7
100
 
8
101
  Highlights: questions that don't block move into the chat header. A **📓**
package/README.md CHANGED
@@ -45,29 +45,29 @@ flowchart LR
45
45
  <table>
46
46
  <tr>
47
47
  <td width="42%" rowspan="4" valign="top">
48
- <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.22" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
48
+ <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.23" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
49
49
  <br><sub>On a phone: the same conversations, installable as an app.</sub>
50
50
  </td>
51
51
  <td width="58%" valign="top">
52
- <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.22" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
52
+ <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.23" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
53
53
  <br><sub>Agents working in parallel, each with live token counts and its own Stop.</sub>
54
54
  </td>
55
55
  </tr>
56
56
  <tr>
57
57
  <td valign="top">
58
- <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.22" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
58
+ <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.23" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
59
59
  <br><sub>When the agent needs a decision, it asks, and waits for your answer.</sub>
60
60
  </td>
61
61
  </tr>
62
62
  <tr>
63
63
  <td valign="top">
64
- <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.22" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
64
+ <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.23" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
65
65
  <br><sub>Read the reasoning, not just the answer.</sub>
66
66
  </td>
67
67
  </tr>
68
68
  <tr>
69
69
  <td valign="top">
70
- <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.22" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
70
+ <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.23" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
71
71
  <br><sub>Agents that watch agents: woken by events, approving nothing.</sub>
72
72
  </td>
73
73
  </tr>
@@ -118,11 +118,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
118
118
  <table>
119
119
  <tr>
120
120
  <td width="62%" valign="top">
121
- <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.22" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
121
+ <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.23" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
122
122
  <br><sub>Send a message while the agent works, and it steers the running turn.</sub>
123
123
  </td>
124
124
  <td width="38%" valign="top">
125
- <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.22" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
125
+ <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.23" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
126
126
  <br><sub>What each plan has left, in one place.</sub>
127
127
  </td>
128
128
  </tr>
@@ -139,7 +139,7 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
139
139
  - **Share** a conversation as a read-only link, hide single messages and the host's paths from it, and see when it was opened ([Sharing](docs/web-ui.md#sharing)).
140
140
  - **On your phone**: install OAR as an app and get push notifications; back walks through the conversations you opened, a pinch zooms the whole app, and held sideways it keeps a phone's layout ([Notifications and the app](docs/web-ui.md#notifications-and-the-app), [back and forward](docs/web-ui.md#back-and-forward), [zoom](docs/web-ui.md#zoom), [a phone held sideways](docs/web-ui.md#a-phone-held-sideways)).
141
141
 
142
- The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.22](CHANGELOG.md#0922--2026-11-05) as the latest.
142
+ The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.23](CHANGELOG.md#0923--2026-11-07) as the latest.
143
143
 
144
144
  ## Remote access
145
145
 
@@ -190,7 +190,7 @@ flowchart LR
190
190
 
191
191
  You stay in charge: per paired relay you decide whether agents may only read, also prompt, or do everything, and in the `ask` and `plan` modes every write asks you first. A subscription only notifies; it approves nothing.
192
192
 
193
- <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.22"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.22"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.22" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
193
+ <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.23"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.23"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.23" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
194
194
 
195
195
  How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
196
196
 
package/bin/oar.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from '../server/services/oar-state-migration-service.mjs';
15
15
  import { readConfiguredDataDir, resolveDataDir, resolveUploadDir } from '../server/services/data-dir-resolution.mjs';
16
16
  import { RESCUE_DIR_NAME, TAKEOVER_PROGRESS_FILE } from '../server/services/earlier-database-service.mjs';
17
+ import { NATIVE_BUILD_COMMAND, buildBetterSqlite3, needsSourceBuild } from '../server/services/native-addon-build-service.mjs';
17
18
  import { DB_FILE_NAME, DB_FILE_NAMES, LEGACY_DB_FILE_NAME, resolveDatabaseFile } from '../server/services/database-file.mjs';
18
19
  import { BACKUP_PROGRESS_FILE, BACKUP_WAIT_FILE, backupDatabase, configWarnings, describeBackups, sanitizeReason } from '../server/services/db-backup-service.mjs';
19
20
  import { defaultIsProcessAlive, lockHolderMayRun, processIdentity } from '../server/services/relay-singleton-guard.mjs';
@@ -2407,6 +2408,15 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
2407
2408
  return { code: 1, reason: 'npm-failed' };
2408
2409
  }
2409
2410
  logger.log(`[oar] Installed @oar-sh/oar@${target}.`);
2411
+ // No prebuilt SQLite binding here: build it before the relay restarts on
2412
+ // the new version (native-addon-build-service.mjs).
2413
+ if (needsSourceBuild()) {
2414
+ const build = await buildBetterSqlite3({ packageRoot, env: npm.env, spawnImpl, logger, onOutput: (text) => process.stdout.write(text) });
2415
+ if (!build.ok) {
2416
+ logger.error(`[oar] The relay keeps running the version it has loaded. Run ${NATIVE_BUILD_COMMAND}, then restart it.`);
2417
+ return { code: 1, reason: 'native-build-failed' };
2418
+ }
2419
+ }
2410
2420
 
2411
2421
  // A running relay picks the new code up on restart; ask for the queue-idle
2412
2422
  // deferred one so no in-flight turn is cut off.
@@ -2446,6 +2456,23 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
2446
2456
  return { code: 0, reason: 'updated' };
2447
2457
  }
2448
2458
 
2459
+ /**
2460
+ * `oar build-native [--force]`: the SQLite binding of better-sqlite3 where no
2461
+ * prebuilt one ships (Termux, 32-bit ARM, FreeBSD); elsewhere nothing to do,
2462
+ * so an installer may run it everywhere. `--force` builds anyway, which only
2463
+ * tests the toolchain where a prebuilt binding ships: better-sqlite3 loads
2464
+ * that one first.
2465
+ */
2466
+ async function runBuildNative({ argv = [], env = process.env, logger = console, spawnImpl = spawn } = {}) {
2467
+ const force = argv.includes('--force');
2468
+ if (!force && !needsSourceBuild()) {
2469
+ logger.log(`[oar] better-sqlite3 ships a prebuilt SQLite binding for ${process.platform}-${process.arch}; nothing to build.`);
2470
+ return { code: 0, reason: 'prebuilt' };
2471
+ }
2472
+ const result = await buildBetterSqlite3({ packageRoot: resolvePackageRoot(), force, env, spawnImpl, logger, onOutput: (text) => process.stdout.write(text) });
2473
+ return result.ok ? { code: 0, reason: 'built' } : { code: 1, reason: 'build-failed' };
2474
+ }
2475
+
2449
2476
  /** `oar` without a command: the menu in a terminal, the status and the usage elsewhere. */
2450
2477
  async function runMenuCommand(options = {}) {
2451
2478
  const ctx = cliContext(options);
@@ -2495,6 +2522,7 @@ const COMMANDS = {
2495
2522
  doctor: runDoctor,
2496
2523
  backup: runBackup,
2497
2524
  update: runUpdate,
2525
+ 'build-native': runBuildNative,
2498
2526
  help: async ({ logger = console } = {}) => {
2499
2527
  logger.log(usageText());
2500
2528
  return { code: 0, reason: 'help' };
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.22",
3
+ "version": "0.9.23",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
7
+ "author": "Simon",
7
8
  "homepage": "https://oar.sh",
8
9
  "repository": {
9
10
  "type": "git",
10
11
  "url": "git+https://github.com/oar-sh/oar.git"
11
12
  },
13
+ "bugs": {
14
+ "url": "https://github.com/oar-sh/oar/issues"
15
+ },
12
16
  "engines": {
13
17
  "node": ">=22.13"
14
18
  },
@@ -41,7 +45,6 @@
41
45
  "LICENSE"
42
46
  ],
43
47
  "scripts": {
44
- "preinstall": "node server/services/package-data-rescue-preinstall.mjs",
45
48
  "start": "node server/server.js",
46
49
  "copilot:relay": "gh copilot -- --allow-all",
47
50
  "test": "node --import ./scripts/node-test-keep-alive.mjs --import ./scripts/node-test-worktree-guard.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
@@ -55,20 +58,16 @@
55
58
  "test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
56
59
  },
57
60
  "dependencies": {
58
- "@anthropic-ai/claude-agent-sdk": "0.3.304",
59
- "@cursor/sdk": "1.0.38",
60
- "@github/copilot-sdk": "1.0.23",
61
- "better-sqlite3": "12.11.1",
62
- "express": "4.22.3",
61
+ "@anthropic-ai/claude-agent-sdk": "0.3.319",
62
+ "@cursor/sdk": "1.3.1",
63
+ "@github/copilot-sdk": "1.0.29",
64
+ "better-sqlite3": "13.0.3",
65
+ "express": "5.3.0",
63
66
  "qrcode-terminal": "0.12.0",
64
- "socket.io": "4.8.3",
65
- "uuid": "14.0.1",
67
+ "socket.io": "4.8.4",
66
68
  "web-push": "3.6.7",
67
- "ws": "8.21.3",
68
- "zod": "4.4.3"
69
- },
70
- "optionalDependencies": {
71
- "tty-console": "1.0.4"
69
+ "ws": "8.22.1",
70
+ "zod": "4.6.7"
72
71
  },
73
72
  "devDependencies": {
74
73
  "@playwright/test": "^1.60.0",
@@ -22,7 +22,7 @@
22
22
  * SSL_CERT_FILE and SSL_CERT_DIR: '' empties OpenSSL's and Node's system
23
23
  * store, so those two are pinned only when the worker sets them.
24
24
  *
25
- * Source: the names Claude Code 2.1.304 (the bundled CLI) reads, by a grep
25
+ * Source: the names Claude Code 2.1.319 (the bundled CLI) reads, by a grep
26
26
  * of the binary; the CLI's own lists of endpoints (with their selection
27
27
  * switches and companions) and credentials are in it as they are there. A
28
28
  * deny-list, because a settings layer can only pin names known in advance.
@@ -82,6 +82,7 @@ export const CLAUDE_CLI_PINNED_ENV = Object.freeze([
82
82
  'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
83
83
  'CLAUDE_CODE_OAUTH_SCOPES',
84
84
  'CLAUDE_CODE_OAUTH_CLIENT_ID',
85
+ 'CLAUDE_CODE_SESSIONS_OAUTH_TOKEN',
85
86
  'ANTHROPIC_PROFILE',
86
87
  'ANTHROPIC_IDENTITY_TOKEN',
87
88
  'ANTHROPIC_IDENTITY_TOKEN_FILE',
@@ -106,6 +107,8 @@ export const CLAUDE_CLI_PINNED_ENV = Object.freeze([
106
107
  'NODE_TLS_REJECT_UNAUTHORIZED',
107
108
  'NODE_EXTRA_CA_CERTS',
108
109
  'CLAUDE_CODE_CERT_STORE',
110
+ 'CLAUDE_CODE_DISABLE_LOOPED_CROSS_CERT_DROP',
111
+ 'CLAUDE_CODE_DISABLE_SELF_NAMED_CERT_DROP',
109
112
  'CLAUDE_CODE_CLIENT_CERT',
110
113
  'CLAUDE_CODE_CLIENT_KEY',
111
114
  'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE',
@@ -163,6 +163,13 @@ export function shouldRequireNewOpenAIConversation({
163
163
  );
164
164
  }
165
165
 
166
+ // The path a `{*splat}` route matched: express 5 hands it over as its
167
+ // decoded segments, or not at all when the path ends at the slash.
168
+ export function splatPath(req) {
169
+ const splat = req.params?.splat;
170
+ return Array.isArray(splat) ? splat.join('/') : String(splat || '');
171
+ }
172
+
166
173
  function normalizeRequestedProviderType(value = '') {
167
174
  const normalized = String(value || '').trim().toLowerCase();
168
175
  if (normalized === 'openai' || normalized === 'openai-byok' || normalized === 'openai-image') return 'openai';
@@ -2142,8 +2149,6 @@ export function registerMessagesRoutes(app, deps) {
2142
2149
  deepseekClaudeConfigDir = '',
2143
2150
  } = deps;
2144
2151
 
2145
- const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
2146
-
2147
2152
  // ─── Sub-agent runs: header, timeline, live events ─────────────────────────
2148
2153
  const subagentTimeline = createSubagentTimelineService({ db, stmts });
2149
2154
 
@@ -2255,18 +2260,6 @@ export function registerMessagesRoutes(app, deps) {
2255
2260
  LIMIT 1
2256
2261
  `);
2257
2262
 
2258
- function isAbnormalWorkerTelemetry(payload = {}, level = 'log') {
2259
- const normalizedLevel = String(level || 'log').trim().toLowerCase();
2260
- if (normalizedLevel === 'warn' || normalizedLevel === 'error') return true;
2261
- const event = String(payload?.event || '').trim().toLowerCase();
2262
- const state = String(payload?.state || '').trim().toLowerCase();
2263
- if (state === 'error') return true;
2264
- return event.includes('error')
2265
- || event.includes('failed')
2266
- || event.includes('failure')
2267
- || event.includes('blocked');
2268
- }
2269
-
2270
2263
  function readBridgeIdentity(req) {
2271
2264
  return relayBridgeOwnerService?.normalizeIdentity?.({
2272
2265
  pid: req.headers['x-relay-process-pid'],
@@ -2340,8 +2333,6 @@ export function registerMessagesRoutes(app, deps) {
2340
2333
  const payload = extra && typeof extra === 'object'
2341
2334
  ? { ...envelope, ...extra }
2342
2335
  : envelope;
2343
- const shouldLog = !ttyConsoleActive || isAbnormalWorkerTelemetry(payload, level);
2344
- if (!shouldLog) return;
2345
2336
  const method = typeof console[level] === 'function' ? level : 'log';
2346
2337
  console[method](`[${ts()}] WORKER ${JSON.stringify(payload)}`);
2347
2338
  }
@@ -4021,8 +4012,8 @@ export function registerMessagesRoutes(app, deps) {
4021
4012
  });
4022
4013
  });
4023
4014
 
4024
- app.get('/api/files/*', auth, (req, res) => {
4025
- const requestedPath = String(req.params?.[0] || '').trim();
4015
+ app.get('/api/files/{*splat}', auth, (req, res) => {
4016
+ const requestedPath = splatPath(req).trim();
4026
4017
  const rootOverride = resolveScopedWorkspaceRootPath(req);
4027
4018
  const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
4028
4019
  if (!filePath) return res.status(400).json({ error: 'Invalid file path' });
@@ -4043,8 +4034,8 @@ export function registerMessagesRoutes(app, deps) {
4043
4034
  });
4044
4035
  });
4045
4036
 
4046
- app.get('/api/files-preview/*', auth, (req, res) => {
4047
- const requestedPath = String(req.params?.[0] || '').trim();
4037
+ app.get('/api/files-preview/{*splat}', auth, (req, res) => {
4038
+ const requestedPath = splatPath(req).trim();
4048
4039
  const normalizedPath = normalizeWorkspaceRelativePath(requestedPath);
4049
4040
  const rootOverride = resolveScopedWorkspaceRootPath(req);
4050
4041
  const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
@@ -161,7 +161,7 @@ export { mapUsageSnapshotRow };
161
161
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
162
162
  const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing', 'parked']);
163
163
  // Registry statuses that mean "this worker is currently running" (same triple
164
- // as countActiveCliWorkers in server-runtime.mjs).
164
+ // as ACTIVE_SESSION_CWD_STATUSES in server-runtime.mjs).
165
165
  const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
166
166
  // A shared attachment's URL names its bytes (sha256 or a fixed generated-image
167
167
  // path), so a kept answer stays right.
@@ -4,7 +4,7 @@ import express from 'express';
4
4
  import http from 'http';
5
5
  import { Server } from 'socket.io';
6
6
  import { WebSocketServer } from 'ws';
7
- import { v4 as uuidv4 } from 'uuid';
7
+ import { randomUUID as uuidv4 } from 'crypto';
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { execFile, execFileSync, spawn } from 'child_process';
@@ -25,7 +25,7 @@ import {
25
25
  } from './services/workspace-root-path-policy.mjs';
26
26
  import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
27
27
  import { createDetachedWorkerWatch } from './services/detached-worker-watch-service.mjs';
28
- import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
28
+ import { applyCdnNoStoreHeaders, sendOwnFile } from './services/file-serving.mjs';
29
29
  import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
30
30
  import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
31
31
  import { PLAN_BOARD_APPROVE_ONLY, planBoardShownActions } from '../shared/plan-board-actions.mjs';
@@ -196,8 +196,8 @@ import {
196
196
  resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
197
197
  resolveLaunchWorkspaceRootPath,
198
198
  } from './services/workspace-root-defaults-service.mjs';
199
- import { maybeStartTtyConsole } from './tty-console-bootstrap.mjs';
200
199
  import { installRelayConsoleLog, resolveRelayConsoleLogPath } from './services/relay-console-log.mjs';
200
+ import { isMissingSqliteBindingError, missingSqliteBindingLines } from './services/native-addon-build-service.mjs';
201
201
  import {
202
202
  computeFeatureFlagState,
203
203
  FEATURE_REGISTRY,
@@ -327,13 +327,15 @@ const PWA_VERSION_PLACEHOLDER = /const __PWA_VERSION = '[^']*';/;
327
327
  // after a restart or an incident.
328
328
  const relayConsoleLogPath = resolveRelayConsoleLogPath({ env: process.env, serverDir: __dirname });
329
329
  const relayConsoleLog = installRelayConsoleLog({ logPath: relayConsoleLogPath });
330
- const ttyConsoleRuntime = await maybeStartTtyConsole({
331
- serverDir: __dirname,
332
- logsDir: path.join(__dirname, 'logs'),
333
- logger: console,
334
- });
335
- // The terminal console takes the console's methods over when it starts.
336
- relayConsoleLog?.rewrap();
330
+ // Where better-sqlite3 ships no binary (Termux, 32-bit ARM) the binding is
331
+ // built by `oar build-native`; until then say so instead of a stack trace.
332
+ try {
333
+ new Database(':memory:').close();
334
+ } catch (error) {
335
+ if (!isMissingSqliteBindingError(error)) throw error;
336
+ for (const line of missingSqliteBindingLines({ error })) console.error(`[startup] ${line}`);
337
+ process.exit(1);
338
+ }
337
339
  // TEMP and TMP for this relay and everything it starts (normalizeTempEnv).
338
340
  for (const change of normalizeTempEnv()) console.log(`[startup] ${change} (quotes taken off, or the folder is not there)`);
339
341
 
@@ -2112,7 +2114,6 @@ function workspaceRootPayload() {
2112
2114
  }
2113
2115
 
2114
2116
  const ACTIVE_SESSION_CWD_STATUSES = new Set(['starting', 'ready', 'processing']);
2115
- const ACTIVE_TTY_CLI_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
2116
2117
 
2117
2118
  function normalizeConversationWorkspaceRootPath(candidatePath) {
2118
2119
  const normalized = normalizeWorkspaceRootPath(candidatePath);
@@ -3760,27 +3761,6 @@ function queueCounts() {
3760
3761
  return { pendingCount: map.pending || 0, processingCount: map.processing || 0, parkedCount: map.parked || 0 };
3761
3762
  }
3762
3763
 
3763
- function countActiveCliWorkers() {
3764
- const workers = sessionWorkerRegistry?.listWorkers?.() || [];
3765
- return workers.reduce((acc, worker) => {
3766
- const status = String(worker?.status || '').trim().toLowerCase();
3767
- return ACTIVE_TTY_CLI_WORKER_STATUSES.has(status) ? acc + 1 : acc;
3768
- }, 0);
3769
- }
3770
-
3771
- function attachTtyConsoleRuntimeTitle(runtime) {
3772
- const addConsoleTitle = runtime?.tty?.addConsoleTitle;
3773
- if (typeof addConsoleTitle !== 'function') return;
3774
- addConsoleTitle(() => {
3775
- const counts = queueCounts();
3776
- const connectedClients = Number(io?.sockets?.sockets?.size || 0);
3777
- const activeCliWorkers = countActiveCliWorkers();
3778
- const cliState = cliOnline ? 'online' : 'offline';
3779
- return `Clients: ${connectedClients} :: Pen: ${counts.pendingCount} :: Proc: ${counts.processingCount} :: parked: ${counts.parkedCount} :: CLIs: ${activeCliWorkers} :: CLI: ${cliState}`;
3780
- });
3781
- }
3782
- attachTtyConsoleRuntimeTitle(ttyConsoleRuntime);
3783
-
3784
3764
  // ─── Queued relay restart / shutdown ─────────────────────────────────────────
3785
3765
  // A request waits until no turn is processing and holds new turns meanwhile
3786
3766
  // (services/relay-shutdown-gate.mjs); past the maximum wait it asks instead
@@ -7430,6 +7410,12 @@ app.use(tunnelWorkerPathGuard.requestMiddleware);
7430
7410
  // Uploads travel as raw bodies on POST /api/upload (express.raw, capped by
7431
7411
  // MAX_UPLOAD_BYTES), so the JSON limit only bounds inline image data URLs.
7432
7412
  app.use(express.json({ limit: '20mb' }));
7413
+ // Express 5 leaves req.body undefined when no parser read a body (a GET, a
7414
+ // POST without a JSON body); the routes read it as express 4 gave it, {}.
7415
+ app.use((req, _res, next) => {
7416
+ if (req.body === undefined) req.body = {};
7417
+ next();
7418
+ });
7433
7419
  app.use((req, _res, next) => {
7434
7420
  stripRequestPathPrefix(req, remotePath);
7435
7421
  next();
@@ -7461,9 +7447,7 @@ app.use((req, res, next) => {
7461
7447
  });
7462
7448
  app.get('/socket.io/socket.io.js', (req, res, next) => {
7463
7449
  res.setHeader('Cache-Control', 'no-store');
7464
- res.sendFile(SOCKET_IO_CLIENT_JS_PATH, (error) => {
7465
- if (error) next(error);
7466
- });
7450
+ sendOwnFile(res, SOCKET_IO_CLIENT_JS_PATH, next);
7467
7451
  });
7468
7452
  app.get('/manifest.webmanifest', (req, res, next) => {
7469
7453
  try {
@@ -7477,9 +7461,7 @@ app.get('/manifest.webmanifest', (req, res, next) => {
7477
7461
  });
7478
7462
  app.get('/sw.js', (req, res, next) => {
7479
7463
  res.setHeader('Cache-Control', 'no-store');
7480
- res.sendFile(PWA_SW_PATH, (error) => {
7481
- if (error) next(error);
7482
- });
7464
+ sendOwnFile(res, PWA_SW_PATH, next);
7483
7465
  });
7484
7466
  app.get(['/', '/index.html'], (req, res, next) => {
7485
7467
  try {
@@ -7554,7 +7536,6 @@ const runtimeState = {
7554
7536
  // Resumed: what was queued while paused goes out now.
7555
7537
  if (wasPaused && !relayPaused) wakeSessionWorkerQueue('relay-resumed');
7556
7538
  },
7557
- get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
7558
7539
  get relayShutdown() { return relayRestartQueue.getState(); },
7559
7540
  // A shutdown or restart waits for the processing turns: no pending row
7560
7541
  // goes out meanwhile (services/relay-restart-queue.mjs).
@@ -8219,7 +8200,7 @@ if (runtimeBindingsBootstrapped > 0) {
8219
8200
 
8220
8201
  function ts() { return new Date().toISOString().slice(11, 23); }
8221
8202
  function runtimeLogPrefix() {
8222
- return ttyConsoleRuntime?.tty ? '' : `[${ts()}] `;
8203
+ return `[${ts()}] `;
8223
8204
  }
8224
8205
 
8225
8206
  // ─── Auth Middleware ──────────────────────────────────────────────────────────
@@ -12,7 +12,7 @@ import { resolveStateLayout } from './oar-state-layout.mjs';
12
12
  import { defaultIsProcessAlive, lockHolderMayRun, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
13
13
 
14
14
  const SHA256_NAME = /^[a-f0-9]{64}$/;
15
- // The install-time rescue's copies (package-data-rescue.mjs), in the data folder.
15
+ // The copies the install-time rescue of 0.9.14 to 0.9.22 made, in the data folder.
16
16
  export const RESCUE_DIR_NAME = 'rescued-package-data';
17
17
  // What was looked at already: never offered again unless it changed.
18
18
  export const CHECKED_FILE = 'earlier-databases.json';
@@ -60,6 +60,19 @@ export function withFileVersion(url, version) {
60
60
  return appendUrlParam(String(url || ''), 'v', String(version || ''));
61
61
  }
62
62
 
63
+ /**
64
+ * One of the relay's own files (sw.js, the socket.io client) through
65
+ * res.sendFile, rooted at its folder. Given a bare absolute path, express 5's
66
+ * send refuses any path with a dot folder in it (dotfiles 'ignore' looks at
67
+ * every segment without a root): a 404 for an install under ~/.oar, nvm or
68
+ * ~/.npm-global. With the folder as root only the file name is looked at.
69
+ */
70
+ export function sendOwnFile(res, absolutePath, next) {
71
+ res.sendFile(path.basename(absolutePath), { root: path.dirname(absolutePath) }, (error) => {
72
+ if (error) next(error);
73
+ });
74
+ }
75
+
63
76
  /**
64
77
  * `cacheControl` replaces the no-store default for content whose URL names
65
78
  * its bytes (an upload addressed by its hash): such an answer may be kept.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * better-sqlite3 13 ships its binary for linux (glibc and musl), darwin and
3
+ * win32 on x64 and arm64, and runs no install script. Anywhere else (Android
4
+ * under Termux, 32-bit ARM, FreeBSD) the first database open finds no binary
5
+ * and throws: the binding has to be compiled from the sources the package
6
+ * ships. OAR runs that build itself, after an update (the Update button,
7
+ * `oar update`) and as `oar build-native`, never as an install script of its
8
+ * own package: one would bring back the install-script warning every package
9
+ * scanner shows for OAR, and npm 12 skips it unless the user allows it.
10
+ *
11
+ * The build is node-gyp's, the one npm bundles (npx's as the fallback), in the
12
+ * package's own folder with `--force_build=1`, as the package's
13
+ * `build-release` script runs it. It needs Python 3, make and a C++ compiler.
14
+ */
15
+ import { spawn as defaultSpawn } from 'child_process';
16
+ import fs from 'fs';
17
+ import { createRequire } from 'module';
18
+ import path from 'path';
19
+
20
+ import { killTree, runToCompletion, tailOf } from './cli-process-runner.mjs';
21
+
22
+ export const BETTER_SQLITE3_PREBUILD_PLATFORMS = Object.freeze(['linux', 'darwin', 'win32']);
23
+ export const BETTER_SQLITE3_PREBUILD_ARCHS = Object.freeze(['x64', 'arm64']);
24
+ export const NATIVE_BUILD_COMMAND = 'oar build-native';
25
+ // sqlite3.c is one large file: a phone takes minutes for it.
26
+ export const NATIVE_BUILD_TIMEOUT_MS = 30 * 60_000;
27
+ export const TERMUX_BUILD_TOOLS = 'pkg install python clang make';
28
+
29
+ const BINDING_FILE = path.join('build', 'Release', 'better_sqlite3.node');
30
+
31
+ /** True where better-sqlite3 has no prebuilt binary and must be compiled. */
32
+ export function needsSourceBuild({ platform = process.platform, arch = process.arch } = {}) {
33
+ return !(BETTER_SQLITE3_PREBUILD_PLATFORMS.includes(platform) && BETTER_SQLITE3_PREBUILD_ARCHS.includes(arch));
34
+ }
35
+
36
+ /** The folder of the better-sqlite3 this package loads, or null. */
37
+ export function resolveBetterSqlite3Dir({ packageRoot, requireFrom = createRequire } = {}) {
38
+ try {
39
+ const require = requireFrom(path.join(path.resolve(String(packageRoot || '.')), 'package.json'));
40
+ return path.dirname(require.resolve('better-sqlite3/package.json'));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** The error better-sqlite3 throws when it finds neither a prebuilt nor a built binary. */
47
+ export function isMissingSqliteBindingError(error) {
48
+ const code = String(error?.code || '');
49
+ return (code === 'MODULE_NOT_FOUND' || code === 'ERR_DLOPEN_FAILED')
50
+ && /better_sqlite3\.node|better-sqlite3[\\/]prebuilds[\\/][^\\/]+\.node/.test(String(error?.message || ''));
51
+ }
52
+
53
+ /**
54
+ * The lines a relay prints, instead of a stack trace, when it cannot open a
55
+ * database because the binding is missing.
56
+ */
57
+ export function missingSqliteBindingLines({ platform = process.platform, arch = process.arch, error = null } = {}) {
58
+ const target = `${platform}-${arch}`;
59
+ if (!needsSourceBuild({ platform, arch })) {
60
+ // There, but not loadable on this system. better-sqlite3 loads its
61
+ // prebuilt binding before a built one, so no build here would be used.
62
+ if (String(error?.code || '') === 'ERR_DLOPEN_FAILED') {
63
+ return [
64
+ `The SQLite binding that better-sqlite3 ships for ${target} does not load on this system (${String(error?.message || '').split('\n')[0]}).`,
65
+ 'Its C library is too old for it, or the system is not one the binding supports.',
66
+ 'Update the operating system, or run the relay on a supported one.',
67
+ ];
68
+ }
69
+ return [
70
+ `The SQLite binding of better-sqlite3 for ${target} is missing from the installed package (${String(error?.message || '').split('\n')[0]}).`,
71
+ 'Reinstall OAR: npm install -g @oar-sh/oar',
72
+ ];
73
+ }
74
+ return [
75
+ `better-sqlite3 has no prebuilt SQLite binding for ${target}, and it has not been built here yet.`,
76
+ `Build it with: ${NATIVE_BUILD_COMMAND}`,
77
+ platform === 'android'
78
+ ? `It needs Python 3, make and a C++ compiler: ${TERMUX_BUILD_TOOLS}`
79
+ : 'It needs Python 3, make and a C++ compiler.',
80
+ ];
81
+ }
82
+
83
+ // The node-gyp that ships inside npm, beside this Node; null when there is none.
84
+ function bundledNodeGyp({ execPath, platform, existsImpl, realpathImpl }) {
85
+ const pathImpl = platform === 'win32' ? path.win32 : path.posix;
86
+ const nodeDir = pathImpl.dirname(String(execPath || ''));
87
+ const npmRoots = [];
88
+ if (platform !== 'win32') {
89
+ // <prefix>/bin/npm is a link to <prefix>/lib/node_modules/npm/bin/npm-cli.js.
90
+ try { npmRoots.push(pathImpl.dirname(pathImpl.dirname(realpathImpl(pathImpl.join(nodeDir, 'npm'))))); } catch {}
91
+ npmRoots.push(pathImpl.join(nodeDir, '..', 'lib', 'node_modules', 'npm'));
92
+ } else {
93
+ npmRoots.push(pathImpl.join(nodeDir, 'node_modules', 'npm'));
94
+ }
95
+ for (const root of npmRoots) {
96
+ const file = pathImpl.join(root, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js');
97
+ if (existsImpl(file)) return file;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * How to run node-gyp: npm's own (npm_config_node_gyp when npm set it), else
104
+ * `npx --yes node-gyp`. Returns { command, args, env }.
105
+ */
106
+ export function resolveNodeGypInvocation({
107
+ execPath = process.execPath,
108
+ platform = process.platform,
109
+ env = process.env,
110
+ existsImpl = fs.existsSync,
111
+ realpathImpl = fs.realpathSync,
112
+ } = {}) {
113
+ const pathImpl = platform === 'win32' ? path.win32 : path.posix;
114
+ const nodeDir = pathImpl.dirname(String(execPath || ''));
115
+ const separator = platform === 'win32' ? ';' : ':';
116
+ const pathKey = Object.keys(env || {}).find((key) => key.toUpperCase() === 'PATH') || 'PATH';
117
+ // node-gyp runs `node` from the gyp files: this Node first on its PATH.
118
+ const childEnv = { ...env, [pathKey]: [nodeDir, String(env?.[pathKey] || '')].filter(Boolean).join(separator) };
119
+ // Node for Termux has no android_ndk_path in its common.gypi, which gyp
120
+ // then refuses as undefined; an empty one builds with Termux's own clang.
121
+ if (platform === 'android' && !/\bandroid_ndk_path=/.test(String(env?.GYP_DEFINES || ''))) {
122
+ childEnv.GYP_DEFINES = [String(env?.GYP_DEFINES || '').trim(), "android_ndk_path=''"].filter(Boolean).join(' ');
123
+ }
124
+ const fromNpm = String(env?.npm_config_node_gyp || '').trim();
125
+ const gyp = (fromNpm && existsImpl(fromNpm) ? fromNpm : null) || bundledNodeGyp({ execPath, platform, existsImpl, realpathImpl });
126
+ if (gyp) return { command: execPath, args: [gyp], env: childEnv };
127
+ if (platform === 'win32') {
128
+ // npx.cmd runs through a shell, which splits a path with a space in it
129
+ // (C:\Program Files): this Node with npm's npx-cli.js, else npx.cmd by name.
130
+ const npxCli = pathImpl.join(nodeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js');
131
+ if (existsImpl(npxCli)) return { command: execPath, args: [npxCli, '--yes', 'node-gyp'], env: childEnv };
132
+ return { command: 'npx.cmd', args: ['--yes', 'node-gyp'], env: childEnv };
133
+ }
134
+ const npx = pathImpl.join(nodeDir, 'npx');
135
+ return { command: existsImpl(npx) ? npx : 'npx', args: ['--yes', 'node-gyp'], env: childEnv };
136
+ }
137
+
138
+ // What a failed build's output says is missing, as one line to act on.
139
+ function missingToolHint(output, platform) {
140
+ const text = String(output || '');
141
+ const missing = /find Python|Could not find any Python|python3?: (?:not found|command not found)/i.test(text)
142
+ || /make: (?:not found|command not found)|not found: make|spawn make ENOENT/i.test(text)
143
+ || /(?:c\+\+|g\+\+|clang\+\+|cc|clang): (?:not found|command not found)|No such file or directory.*(?:c\+\+|g\+\+|clang)/i.test(text);
144
+ if (!missing) return null;
145
+ return platform === 'android'
146
+ ? `Install the build tools first: ${TERMUX_BUILD_TOOLS}, then run ${NATIVE_BUILD_COMMAND} again.`
147
+ : `Install Python 3, make and a C++ compiler (build-essential on Debian and Ubuntu), then run ${NATIVE_BUILD_COMMAND} again.`;
148
+ }
149
+
150
+ // Opens a database in a child Node with the binding just built, named by its
151
+ // path: the default loader would take a prebuilt one first where one ships.
152
+ function bindingLoads({ dir, execPath, spawnImpl, env }) {
153
+ const binding = path.join(dir, BINDING_FILE);
154
+ const script = `const Database = require(${JSON.stringify(dir)}); new Database(':memory:', { nativeBinding: ${JSON.stringify(binding)} }).close();`;
155
+ return runToCompletion(
156
+ () => spawnImpl(execPath, ['-e', script], { stdio: ['ignore', 'pipe', 'pipe'], env, windowsHide: true }),
157
+ { timeoutMs: 60_000 },
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Builds the binding when this platform has no prebuilt one. `force` builds
163
+ * where one ships too, to check the toolchain; better-sqlite3 still loads its
164
+ * prebuilt binding first there. Resolves (never rejects) with { ok, status, error?, hint? }:
165
+ * status 'prebuilt' (nothing to do), 'built', 'failed'. Every line of the
166
+ * build goes to `onOutput`; what it says about itself goes to `logger.log`.
167
+ */
168
+ export async function buildBetterSqlite3({
169
+ packageRoot,
170
+ force = false,
171
+ platform = process.platform,
172
+ arch = process.arch,
173
+ execPath = process.execPath,
174
+ env = process.env,
175
+ spawnImpl = defaultSpawn,
176
+ existsImpl = fs.existsSync,
177
+ realpathImpl = fs.realpathSync,
178
+ requireFrom = createRequire,
179
+ timeoutMs = NATIVE_BUILD_TIMEOUT_MS,
180
+ onOutput = null,
181
+ onChild = null,
182
+ logger = console,
183
+ } = {}) {
184
+ if (!force && !needsSourceBuild({ platform, arch })) return { ok: true, status: 'prebuilt' };
185
+ const dir = resolveBetterSqlite3Dir({ packageRoot, requireFrom });
186
+ if (!dir) {
187
+ const error = `better-sqlite3 is not installed under ${packageRoot}`;
188
+ logger.error?.(`[oar] ${error}.`);
189
+ return { ok: false, status: 'failed', error };
190
+ }
191
+ const gyp = resolveNodeGypInvocation({ execPath, platform, env, existsImpl, realpathImpl });
192
+ const args = [...gyp.args, 'rebuild', '--release', '--force_build=1'];
193
+ logger.log?.(`[oar] Building the SQLite binding of better-sqlite3 for ${platform}-${arch} in ${dir}; this takes a few minutes.`);
194
+ const result = await runToCompletion(
195
+ () => spawnImpl(gyp.command, args, {
196
+ cwd: dir,
197
+ env: gyp.env,
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ ...(platform === 'win32' ? { windowsHide: true, shell: /\.cmd$/i.test(gyp.command) } : { detached: true }),
200
+ }),
201
+ {
202
+ timeoutMs,
203
+ killChild: (child, signal) => killTree(child, signal, { platform }),
204
+ onChild,
205
+ onOutput,
206
+ },
207
+ );
208
+ if (!result.ok || !existsImpl(path.join(dir, BINDING_FILE))) {
209
+ const error = result.error || `node-gyp exited with code ${result.code}`;
210
+ const hint = missingToolHint(result.output, platform);
211
+ logger.error?.(`[oar] The build of the SQLite binding failed: ${error}.`);
212
+ if (hint) logger.error?.(`[oar] ${hint}`);
213
+ else logger.error?.(`[oar] The end of its output:\n${tailOf(result.output, 2_000)}`);
214
+ return { ok: false, status: 'failed', error, ...(hint ? { hint } : {}) };
215
+ }
216
+ const check = await bindingLoads({ dir, execPath, spawnImpl, env: gyp.env });
217
+ if (!check.ok) {
218
+ const error = `the built binding does not load: ${tailOf(check.output || check.error || '', 500).trim()}`;
219
+ logger.error?.(`[oar] ${error}`);
220
+ return { ok: false, status: 'failed', error };
221
+ }
222
+ logger.log?.('[oar] The SQLite binding is built.');
223
+ return { ok: true, status: 'built' };
224
+ }
@@ -86,6 +86,7 @@ const CLI_COMMANDS = {
86
86
  autostart: { words: ['signin', 'boot', 'off', 'status'], flags: ['--replace'] },
87
87
  uninstall: { flags: ['--purge'] },
88
88
  update: { flags: ['--beta'], values: ['--to'] },
89
+ 'build-native': { flags: ['--force'] },
89
90
  copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
90
91
  };
91
92
 
@@ -116,6 +117,9 @@ export function usageText() {
116
117
  ' oar backup [--reason <text>]',
117
118
  ' A copy of the database into the backup folder (safe while the relay runs)',
118
119
  ' oar update [--beta] [--to <version>]',
120
+ ' oar build-native [--force]',
121
+ ' Build the SQLite binding where no prebuilt one ships (Termux, 32-bit ARM);',
122
+ ' --force builds where one ships too, which only tests the build tools',
119
123
  ' oar help This text',
120
124
  ' oar --version',
121
125
  ].join('\n');
@@ -401,11 +405,10 @@ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
401
405
  * native binary). npm 12 runs no install script it was not told to allow, and
402
406
  * a global install has no package.json of its own to say so; npm 10 and 11
403
407
  * accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
404
- * holds this list to the lockfile.
408
+ * holds this list to the lockfile. better-sqlite3 13 has no install script:
409
+ * where it ships no binary, OAR builds it (native-addon-build-service.mjs).
405
410
  */
406
- // @oar-sh/oar itself: its preinstall copies the data an earlier version
407
- // kept inside the package before npm deletes it (package-data-rescue.mjs).
408
- export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['@oar-sh/oar', 'better-sqlite3', 'koffi']);
411
+ export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['koffi']);
409
412
  export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
410
413
 
411
414
  /**
@@ -8,9 +8,8 @@
8
8
  // read, so the runtime copies it into `<log dir>/relay-console.log`, with a
9
9
  // time stamp per line and a size limit.
10
10
  //
11
- // The copy is taken from the console's methods rather than from stdout: the
12
- // terminal console (tty-console) redraws its prompt on stdout, and none of
13
- // that belongs in a log.
11
+ // The copy is taken from the console's methods rather than from stdout, so
12
+ // what a library writes to stdout directly stays out of it.
14
13
 
15
14
  import fs from 'fs';
16
15
  import path from 'path';
@@ -100,10 +100,10 @@ export function createRemoteRelayLoopbackClient({
100
100
  req.httpVersionMinor = 1;
101
101
  req.httpVersion = '1.1';
102
102
  if (hasBody) {
103
- // What the wire would deliver: JSON, parsed. `_body` tells the JSON
104
- // body parser the body is already there.
103
+ // What the wire would deliver: JSON, parsed. The headers carry no
104
+ // content-length or transfer-encoding, so the JSON body parser finds
105
+ // no body to read and leaves this one in place.
105
106
  req.body = JSON.parse(JSON.stringify(body));
106
- req._body = true;
107
107
  }
108
108
  req.complete = true;
109
109
  req.push(null);
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- import { v4 as uuidv4 } from 'uuid';
3
+ import { randomUUID as uuidv4 } from 'crypto';
4
4
 
5
5
  function normalizeId(value) {
6
6
  return String(value || '').trim();
@@ -11,6 +11,7 @@ import {
11
11
  stripTerminalEscapes,
12
12
  tailOf,
13
13
  } from './cli-process-runner.mjs';
14
+ import { NATIVE_BUILD_COMMAND, buildBetterSqlite3, needsSourceBuild } from './native-addon-build-service.mjs';
14
15
  import { NPM_ALLOW_SCRIPTS_ARG, resolveNpmInvocation } from './oar-cli-helpers.mjs';
15
16
  import { compareSemverIsh, parseSemverIsh } from '../../shared/update-semver.mjs';
16
17
 
@@ -107,6 +108,8 @@ export function createUpdateInstallService({
107
108
  spawnImpl = defaultSpawn,
108
109
  env = process.env,
109
110
  platform = process.platform,
111
+ arch = process.arch,
112
+ buildNativeAddons = buildBetterSqlite3,
110
113
  setTimeoutImpl = setTimeout,
111
114
  clearTimeoutImpl = clearTimeout,
112
115
  nowImpl = Date.now,
@@ -242,15 +245,43 @@ export function createUpdateInstallService({
242
245
  );
243
246
  activeChild = null;
244
247
  flushLogNow();
245
- finishedAt = new Date(nowImpl()).toISOString();
246
248
 
247
249
  if (!result.ok) {
250
+ finishedAt = new Date(nowImpl()).toISOString();
248
251
  state = UPDATE_INSTALL_STATES.ERROR;
249
252
  lastError = result.error || `npm exited with code ${result.code}`;
250
253
  broadcast();
251
254
  return { ok: false, statusCode: 500, error: lastError };
252
255
  }
253
256
 
257
+ // No prebuilt SQLite binding here: build it before the restart, which
258
+ // would start on none (native-addon-build-service.mjs). A failed build
259
+ // keeps this relay running on the binding it has loaded.
260
+ if (needsSourceBuild({ platform, arch })) {
261
+ const toLog = (line) => appendLog(`${line}\n`);
262
+ const build = await buildNativeAddons({
263
+ packageRoot,
264
+ platform,
265
+ arch,
266
+ execPath,
267
+ env: npm.env,
268
+ spawnImpl,
269
+ onOutput: (text) => appendLog(text),
270
+ onChild: (child) => { activeChild = child; },
271
+ logger: { log: toLog, error: toLog },
272
+ });
273
+ activeChild = null;
274
+ flushLogNow();
275
+ if (!build.ok) {
276
+ finishedAt = new Date(nowImpl()).toISOString();
277
+ state = UPDATE_INSTALL_STATES.ERROR;
278
+ lastError = `${UPDATE_PACKAGE_NAME}@${target} is installed, but its SQLite binding did not build (${build.error}). Run ${NATIVE_BUILD_COMMAND}, then restart the relay.`;
279
+ broadcast();
280
+ return { ok: false, statusCode: 500, error: lastError };
281
+ }
282
+ }
283
+ finishedAt = new Date(nowImpl()).toISOString();
284
+
254
285
  // Persist the attempt BEFORE the restart request: the next boot compares
255
286
  // the running version against it and turns "nothing changed" into a
256
287
  // visible failure carrying this log tail.
@@ -1,22 +0,0 @@
1
- /**
2
- * The `preinstall` of @oar-sh/oar. Nothing of OAR loads before the `try`:
3
- * a module of this package that cannot load (a broken file, a missing one)
4
- * must not fail the install on any platform. Only a copy of an old
5
- * database that failed (RescueCopyError) exits 1, so npm stops the update
6
- * and puts the old package, data and all, back in place; anything else is
7
- * one line and exit 0, as this script only ever copies. See
8
- * package-data-rescue.mjs.
9
- */
10
- import { fileURLToPath } from 'node:url';
11
-
12
- try {
13
- const { rescuePackageData } = await import('./package-data-rescue.mjs');
14
- rescuePackageData({ packageRoot: fileURLToPath(new URL('../..', import.meta.url)) });
15
- } catch (error) {
16
- if (error?.rescueCopyFailed === true) {
17
- console.error(`[oar] ${error.message}`);
18
- process.exitCode = 1;
19
- } else {
20
- console.error(`[oar] Did not copy the data of the version being replaced: ${error?.message || error}`);
21
- }
22
- }
@@ -1,139 +0,0 @@
1
- /**
2
- * The preinstall's work (package-data-rescue-preinstall.mjs), on every
3
- * platform. Relays before 0.9.14 could run on the database and uploads
4
- * inside the package (Windows autostart, a plain `gh copilot`), and
5
- * `npm i -g` deletes the old package. While install scripts run, npm keeps
6
- * it as its retired folder (npmRetiredPath; a scan for npm-shaped names only
7
- * when that is missing): its database (-wal, -shm), Cursor stores, uploads
8
- * and config.json are copied, copy only, to
9
- * `<data folder>/rescued-package-data/<time>/` (written as `.partial`, then
10
- * renamed), and the relay takes them over at its next start. Any of them
11
- * makes the old package a source (its uploads beyond the README.md and
12
- * image_input.md it ships). Nothing to copy, or not an installed package:
13
- * nothing happens, nothing is printed. A failed copy throws
14
- * RescueCopyError: exit 1, npm puts the old package back. npm hides a successful script's output, hence the README.txt.
15
- */
16
- import crypto from 'crypto';
17
- import fs from 'fs';
18
- import path from 'path';
19
-
20
- import { DB_FILE_NAMES, sideFiles } from './database-file.mjs';
21
- import { RESCUE_DIR_NAME, copyMissing, isRetiredName } from './earlier-database-service.mjs';
22
- import { resolveRelayStateDirs, resolveStateLayout } from './oar-state-layout.mjs';
23
-
24
- // Old packages only ever held the name before 0.9.20; a later one may hold oar.db.
25
- const DB_FILES = DB_FILE_NAMES.flatMap(sideFiles);
26
-
27
- // What a relay left in a package's server folder; a folder that cannot be
28
- // read throws (no copy failure: nothing was detected yet).
29
- function hasRelayData(fsImpl, serverDir) {
30
- const has = (...parts) => fsImpl.existsSync(path.join(serverDir, ...parts));
31
- const anyUpload = (...parts) => has(...parts) && fsImpl.readdirSync(path.join(serverDir, ...parts)).some((name) => /^[a-f0-9]{64}$/.test(name));
32
- return DB_FILE_NAMES.some((name) => has('data', name)) || has('data', 'cursor-agents') || has('config.json') || anyUpload('uploads') || anyUpload('data', 'uploads');
33
- }
34
-
35
- /** Where npm's reify moves a package it replaces (@npmcli/arborist retire-path.js). */
36
- export function npmRetiredPath(packageDir) {
37
- const hash = crypto.createHash('sha1').update(packageDir).digest('base64').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
38
- return path.resolve(path.dirname(packageDir), `.${path.basename(packageDir)}-${hash}`);
39
- }
40
-
41
- /** A copy of an old database failed: the one error that stops the install. */
42
- export class RescueCopyError extends Error {
43
- constructor(message, options) {
44
- super(message, options);
45
- this.name = 'RescueCopyError';
46
- this.rescueCopyFailed = true;
47
- }
48
- }
49
-
50
- /**
51
- * Returns { status: 'not-installed' | 'nothing' | 'rescued', rescued: [{ from,
52
- * to }] }; throws a RescueCopyError, naming what to save, when a copy fails.
53
- */
54
- export function rescuePackageData({
55
- packageRoot,
56
- env = process.env,
57
- platform = process.platform,
58
- now = () => new Date(),
59
- fsImpl = fs,
60
- logger = console,
61
- } = {}) {
62
- const resolvedRoot = path.resolve(String(packageRoot || ''));
63
- const scope = path.dirname(resolvedRoot);
64
- if (path.basename(scope) !== '@oar-sh' || path.basename(path.dirname(scope)) !== 'node_modules') {
65
- return { status: 'not-installed', rescued: [] };
66
- }
67
- // Detection: npm's retired name, else a scan for npm-shaped names (a path
68
- // in another case hashes differently). Failing here is no copy failure.
69
- let sources;
70
- try {
71
- const exact = npmRetiredPath(resolvedRoot);
72
- const retired = fsImpl.existsSync(exact)
73
- ? [exact]
74
- : fsImpl.readdirSync(scope).filter((name) => isRetiredName(name, path.basename(resolvedRoot))).map((name) => path.join(scope, name));
75
- sources = retired.filter((dir) => hasRelayData(fsImpl, path.join(dir, 'server')));
76
- } catch (error) {
77
- throw new Error(`Could not look for the data of the version being replaced: ${error?.message || error}`, { cause: error });
78
- }
79
- if (!sources.length) return { status: 'nothing', rescued: [] };
80
-
81
- // Old data is there: any failure from here stops the install.
82
- const exists = (file) => fsImpl.existsSync(file);
83
- const kept = path.join(resolvedRoot, 'server');
84
- const copyFailed = (from, to, error) => new RescueCopyError(
85
- `Could not copy the data of the version being replaced (${from}) to ${to}: ${error?.message || error}. `
86
- + `The update stops and the version installed stays as it is; copy ${path.join(kept, 'data')} and ${path.join(kept, 'uploads')} somewhere safe by hand, then update again.`,
87
- { cause: error },
88
- );
89
- let rescueRoot;
90
- try {
91
- const layout = resolveStateLayout({ packageRoot: resolvedRoot, env, platform });
92
- rescueRoot = path.join(resolveRelayStateDirs({ packageRoot: resolvedRoot, env, platform, layout }).dataDir, RESCUE_DIR_NAME);
93
- } catch (error) {
94
- throw copyFailed(path.join(sources[0], 'server'), 'the OAR data folder', error);
95
- }
96
- const stamp = now().toISOString().replace(/[:.]/g, '-');
97
- const rescued = [];
98
- for (const [index, source] of sources.entries()) {
99
- const serverDir = path.join(source, 'server');
100
- let target = path.join(rescueRoot, sources.length > 1 ? `${stamp}-${index}` : stamp);
101
- // Written under a .partial name and renamed when complete: a half copy
102
- // is never taken for a rescue.
103
- try {
104
- for (let n = 1; exists(target) || exists(`${target}.partial`); n += 1) target = `${path.join(rescueRoot, stamp)}-${n}`;
105
- const partial = `${target}.partial`;
106
- fsImpl.mkdirSync(rescueRoot, { recursive: true });
107
- fsImpl.mkdirSync(partial);
108
- fsImpl.mkdirSync(path.join(partial, 'data'));
109
- for (const name of DB_FILES) {
110
- const from = path.join(serverDir, 'data', name);
111
- if (exists(from)) fsImpl.copyFileSync(from, path.join(partial, 'data', name), fs.constants.COPYFILE_EXCL);
112
- }
113
- if (exists(path.join(serverDir, 'data', 'cursor-agents'))) {
114
- copyMissing(fsImpl, path.join(serverDir, 'data', 'cursor-agents'), path.join(partial, 'data', 'cursor-agents'));
115
- }
116
- // Uploads of server/uploads and server/data/uploads, content-addressed.
117
- for (const uploads of [path.join(serverDir, 'uploads'), path.join(serverDir, 'data', 'uploads')]) {
118
- if (exists(uploads)) copyMissing(fsImpl, uploads, path.join(partial, 'uploads'));
119
- }
120
- if (exists(path.join(serverDir, 'config.json'))) {
121
- fsImpl.copyFileSync(path.join(serverDir, 'config.json'), path.join(partial, 'config.json'), fs.constants.COPYFILE_EXCL);
122
- }
123
- fsImpl.writeFileSync(path.join(partial, 'README.txt'), [
124
- `Copied by the @oar-sh/oar update on ${now().toISOString()} from`,
125
- `${serverDir}`,
126
- 'before npm deleted the version it replaced. The relay takes the',
127
- 'database in data\\ over at its next start if it is newer than the',
128
- 'one it has. Nothing here is deleted by OAR.',
129
- '',
130
- ].join('\r\n'));
131
- fsImpl.renameSync(partial, target);
132
- } catch (error) {
133
- throw copyFailed(serverDir, target, error);
134
- }
135
- rescued.push({ from: serverDir, to: target });
136
- logger.log?.(`[oar] Copied the data of the version being replaced from ${serverDir} to ${target}; the relay takes it over at its next start.`);
137
- }
138
- return { status: 'rescued', rescued };
139
- }
@@ -1,75 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { createRequire } from 'module';
4
-
5
- function existingDirectories(paths = []) {
6
- const out = [];
7
- for (const candidate of paths) {
8
- const normalized = String(candidate || '').trim();
9
- if (!normalized) continue;
10
- try {
11
- if (fs.statSync(normalized).isDirectory()) out.push(normalized);
12
- } catch {}
13
- }
14
- return out;
15
- }
16
-
17
- function resolveTtyConsoleRoot() {
18
- const require = createRequire(import.meta.url);
19
- return path.dirname(require.resolve('tty-console'));
20
- }
21
-
22
- export async function maybeStartTtyConsole({
23
- serverDir,
24
- logsDir,
25
- logger = console,
26
- } = {}) {
27
- if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
28
-
29
- const resolvedServerDir = path.resolve(String(serverDir || process.cwd()).trim() || process.cwd());
30
- const resolvedLogsDir = path.resolve(String(logsDir || path.join(resolvedServerDir, 'logs')).trim() || path.join(resolvedServerDir, 'logs'));
31
- fs.mkdirSync(resolvedLogsDir, { recursive: true });
32
-
33
- let ttyConsoleModule;
34
- try {
35
- ttyConsoleModule = await import('tty-console');
36
- } catch (error) {
37
- const code = String(error?.code || '').trim();
38
- if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
39
- logger?.log?.('[server] tty-console not installed; continuing without terminal console.');
40
- return null;
41
- }
42
- logger?.warn?.(`[server] tty-console load failed: ${error?.message || error}`);
43
- return null;
44
- }
45
-
46
- const ttyConsoleApi = ttyConsoleModule?.default || ttyConsoleModule;
47
- const ConsoleCtor = ttyConsoleApi?.Console;
48
- if (typeof ConsoleCtor !== 'function') {
49
- logger?.warn?.('[server] tty-console loaded but Console export was unavailable.');
50
- return null;
51
- }
52
-
53
- const ttyConsoleRoot = resolveTtyConsoleRoot();
54
- const bundledCommandsDir = path.join(ttyConsoleRoot, 'commands');
55
- const localCommandsDir = path.join(resolvedServerDir, 'commands');
56
- const commandsDirs = existingDirectories([bundledCommandsDir, localCommandsDir]);
57
- const configPath = path.join(resolvedLogsDir, 'console_config.json');
58
- const historyPath = path.join(resolvedLogsDir, 'console_history.json');
59
-
60
- const consoleOptions = {
61
- configPath,
62
- historyPath,
63
- exitOnStop: false,
64
- };
65
- if (commandsDirs.length === 1) {
66
- consoleOptions.commandsDir = commandsDirs[0];
67
- } else if (commandsDirs.length > 1) {
68
- consoleOptions.commandsDir = commandsDirs;
69
- }
70
-
71
- const runtime = new ConsoleCtor(consoleOptions);
72
- runtime.start?.();
73
- logger?.log?.(`[server] tty-console started (${commandsDirs.join(', ')})`);
74
- return runtime;
75
- }