@aipoch/open-science 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLI.md CHANGED
@@ -8,7 +8,29 @@ defines the 1.x contract for commands, machine-readable output and exit codes.
8
8
 
9
9
  ## Installation
10
10
 
11
- ### Standalone Node package
11
+ ### From npm
12
+
13
+ Install the published [standalone CLI](https://www.npmjs.com/package/@aipoch/open-science)
14
+ with Node.js **22.13.0 or later**:
15
+
16
+ ```bash
17
+ npm install --global @aipoch/open-science
18
+ open-science start --no-open
19
+ open-science url
20
+ ```
21
+
22
+ The registry package installs the matching native backend, Web UI and resources through npm
23
+ optional dependencies. It runs without Electron or an installed desktop app. Supported targets
24
+ are macOS ARM64/x64, glibc Linux ARM64/x64 and Windows x64; musl Linux and Windows ARM64 are not
25
+ supported. Do not omit optional dependencies. If startup reports a missing native package, run
26
+ `npm install --global @aipoch/open-science --include=optional`.
27
+
28
+ Open the printed authenticated URL to configure an Agent runtime, model provider and optional
29
+ Python/R Notebook runtime, or follow [First-run setup (Codex)](#first-run-setup-codex) from the
30
+ terminal. See the [standalone runtime guide](https://github.com/aipoch/open-science/blob/main/docs/standalone-runtime.md#linux-prerequisites)
31
+ for Linux credential storage and sandbox prerequisites.
32
+
33
+ ### Locally built standalone tarball
12
34
 
13
35
  Install the target-specific standalone tarball built with `npm run pack:backend`, using ordinary
14
36
  Node >=22.13.0. It includes the backend, Web UI, resources and native dependencies; Electron and
@@ -54,25 +76,58 @@ not require a separate Node.js installation.
54
76
  If the launcher directory is not yet on PATH, the Settings panel shows the line to add. Open a new
55
77
  terminal after updating PATH. Choose **Uninstall command** in the same panel to remove the launcher.
56
78
 
57
- ### From npm
79
+ ### From a source checkout
58
80
 
59
- The lightweight CLI/SDK package requires Node.js 22.5 or later. It controls an existing service.
60
- To start an independent service, install the standalone artifact (Node >=22.13.0); the lightweight
61
- package alone does not contain backend resources.
62
- Install it globally after the package is published:
81
+ Replace `open-science` in the examples below with:
63
82
 
64
83
  ```bash
65
- npm install --global @aipoch/open-science
66
- open-science --help
84
+ node packages/open-science/cli.mjs
67
85
  ```
68
86
 
69
- ### From a source checkout
87
+ ## Local information and help
70
88
 
71
- Replace `open-science` in the examples below with:
89
+ These commands work without starting or connecting to the service:
72
90
 
73
91
  ```bash
74
- node packages/open-science/cli.mjs
75
- ```
92
+ open-science --version
93
+ open-science -v
94
+ open-science version --json
95
+ open-science help
96
+ open-science help run
97
+ open-science paths --json
98
+ open-science paths --profile /absolute/path/to/profile
99
+ ```
100
+
101
+ `--version`, `-v`, and `version` print the invoked CLI's application version, including any
102
+ nightly suffix. `version --json` prints `{ "version": "..." }`. Source checkouts use the root
103
+ application version; installed distributions use their own release metadata. This is not the
104
+ version of a running service. The version flags are standalone commands, not options for `run`,
105
+ `start`, or other commands.
106
+
107
+ `help` is equivalent to `--help`; `help <command>` is equivalent to `<command> --help`.
108
+ For example, `help project` lists Project commands. Unknown command names and unsupported
109
+ arguments fail with the existing usage-error exit code `2`.
110
+
111
+ `paths` prints human-readable locations; `paths --json` prints one object with:
112
+
113
+ | Field | Meaning |
114
+ | ---------------------- | ---------------------------------------------------------------------------------------- |
115
+ | `cliPath` | The CLI implementation file used by this invocation. |
116
+ | `nodePath` | The Node executable running the CLI. |
117
+ | `startupConfigRoot` | The configuration directory selected by the installation's default or explicit override. |
118
+ | `discoveryConfigRoots` | Ordered candidate configuration directories searched for an existing service. |
119
+
120
+ Source checkouts default to `~/.open-science-project` for startup; installations default to
121
+ `~/.open-science`. Without an override, service discovery checks the development directory first,
122
+ then the production directory. `--config-root` (or `--profile`) takes precedence over
123
+ `OPEN_SCIENCE_CONFIG_ROOT`, then `OPEN_SCIENCE_STORAGE_ROOT`; an override selects a single
124
+ absolute directory for both startup and discovery. Paths are reported even when they do not exist.
125
+ The output does not identify an active service or a research-data directory, which may differ.
126
+
127
+ These commands do not create profiles, modify settings, read credentials, or print authenticated
128
+ URLs. Successful queries exit `0`; missing or invalid version metadata produces an error rather
129
+ than a fallback version. `paths` accepts only the profile, JSON, and help options; `version`
130
+ accepts only JSON and help options, and `help` accepts only one optional command name and help flags.
76
131
 
77
132
  ## Service lifecycle
78
133
 
@@ -106,7 +161,7 @@ open-science url
106
161
  `open-science url` is the only command that intentionally prints an authenticated browser URL. Normal
107
162
  human-readable, JSON, and JSONL output never includes the local token.
108
163
 
109
- Use `--port <port>` to override `OPEN_SCIENCE_WEB_PORT`, or the default port of `44100` when neither is set. `start` discovers the standalone
164
+ Use `--port <port>` to override `OPEN_SCIENCE_WEB_PORT`, or the default port of `44100` when neither is set. `start` discovers the matching npm platform package, the standalone
110
165
  Node entry bundled beside the CLI, or `out/backend/index.cjs` in a source checkout. Build the
111
166
  source entry with `npm run build:backend` and Web UI with `npm run build:web` first.
112
167
  `--app-path` and `OPEN_SCIENCE_APP_PATH` no longer select a hidden Electron backend.
@@ -166,7 +221,7 @@ nonzero CLI error codes.
166
221
 
167
222
  ## First-run setup (Codex)
168
223
 
169
- Install the standalone artifact or a desktop package containing the ordinary Node backend first.
224
+ Install the npm package, a standalone artifact or a desktop package containing the ordinary Node backend first.
170
225
  `init` creates a configuration directory only; `--profile` selects an absolute configuration directory.
171
226
 
172
227
  ```bash
@@ -249,9 +304,23 @@ open-science runtime list --json
249
304
  ## Upgrading installations
250
305
 
251
306
  Stop the backend before upgrading an installation, then restart it with the same profile.
307
+ For a global npm installation:
308
+
309
+ ```bash
310
+ open-science stop
311
+ npm install --global @aipoch/open-science@latest
312
+ open-science start --no-open
313
+ ```
314
+
315
+ Stop every profile started by that installation before updating it; use `--config-root` for
316
+ custom profiles. To uninstall, stop those services and run
317
+ `npm uninstall --global @aipoch/open-science`. Research data and credentials are retained.
318
+ See [release compatibility](https://github.com/aipoch/open-science/blob/main/docs/compatibility.md)
319
+ before downgrading; reinstalling an older package does not undo data migrations.
320
+
252
321
  Use the installation channel that owns the CLI: Homebrew for a Formula, the new downloaded
253
322
  package for deb/rpm or a local npm tarball, or a verified new Release archive in a new directory
254
- with PATH/the symlink updated. npm registry publication remains deferred.
323
+ with PATH/the symlink updated.
255
324
  The desktop update UI discovers and downloads packages. Automatic replacement remains closed for
256
325
  macOS ZIP, Linux AppImage and Debian until each channel passes complete installed-package
257
326
  acceptance. Windows automatic installation remains disabled; artifact checks and same-version copy tests do not certify a cross-version upgrade.
@@ -537,6 +606,43 @@ turns only and is rejected while root-agent, subagent, or Notebook work is activ
537
606
  does not block the update. Stale revisions fail with `session_revision_conflict`; invalid or
538
607
  unavailable references fail with `invalid_configuration`.
539
608
 
609
+ ## Session package export and import
610
+
611
+ Export a Session to a `.science` package at an explicit path. The Session must be idle:
612
+
613
+ ```bash
614
+ open-science session export <session-id> --project <id-or-name> --output ~/backups/session.science
615
+ ```
616
+
617
+ `--project` resolves a Project by id or name and selects which Session to export. `--output` must
618
+ be an absolute or relative path ending in `.science`; when it is omitted the command fails instead
619
+ of opening a native save dialog, because the CLI runs without a desktop session. The command
620
+ returns the saved status and written path as JSON with `--json`.
621
+
622
+ Both package commands allow up to one hour by default. Use `--timeout-ms <milliseconds>`
623
+ to adjust the request deadline for large packages or slower disks.
624
+
625
+ Import a `.science` package. Import is non-interactive by design and requires an explicit
626
+ confirmation flag:
627
+
628
+ ```bash
629
+ open-science session import ~/backups/session.science --new-project "Restored session" --non-interactive
630
+ open-science session import ~/backups/session.science --project <id-or-name> --non-interactive
631
+ ```
632
+
633
+ `--non-interactive` confirms the import preview on the caller's behalf; without it the command
634
+ fails rather than waiting for an approval that a headless caller can never supply. Choose exactly
635
+ one destination: `--project` imports into an existing Project, `--new-project` creates one with
636
+ the given name, and omitting both uses the package's own Project name in a new Project. The
637
+ package file is read directly from disk, so no upload or drag-and-drop step is involved.
638
+
639
+ Both commands dispatch the same `sessions:export-package`, `sessions:import-package`, and
640
+ `sessions:package-operation` application commands the desktop app uses, over the authenticated
641
+ loopback HTTP channel. They are unavailable to remote Web sessions by design: the channel is
642
+ installed as a rejecting stub for remote callers, and the handler additionally requires a local
643
+ caller, so arbitrary-path file read and write stay confined to loopback callers that hold the
644
+ service token.
645
+
540
646
  ## Agent routing settings
541
647
 
542
648
  Inspect or atomically update the global Agent framework plus Reviewer and Subagent model routing:
package/README.md CHANGED
@@ -1,10 +1,88 @@
1
1
  # @aipoch/open-science
2
2
 
3
- Node.js SDK and command-line client for an Open-Science daemon running on the local machine.
3
+ [AIPOCH Open-Science](https://github.com/aipoch/open-science) is an open-source, local-first,
4
+ model-agnostic AI research workbench. Use scientific Agents, Python and R Notebooks, and
5
+ scientific data connectors to turn research questions into reports, tables and figures.
6
+
7
+ This package provides the **standalone CLI, local Web UI and Node.js SDK**. npm installs the
8
+ matching platform package with the backend and resources; the desktop app is not required.
9
+
10
+ ## Install
11
+
12
+ Install [Node.js](https://nodejs.org/) **22.13.0 or later**, then run:
13
+
14
+ ```bash
15
+ npm install --global @aipoch/open-science
16
+ open-science --help
17
+ ```
18
+
19
+ Supported platforms are macOS Apple Silicon/Intel, Linux ARM64/x64 with glibc, and Windows x64.
20
+ Linux musl (including Alpine) and Windows ARM64 are not supported by the published native packages.
21
+ Keep npm optional dependencies enabled: they contain the platform runtime. If startup reports a
22
+ missing native package, reinstall with `npm install --global @aipoch/open-science --include=optional`.
23
+
24
+ Linux also needs working credential storage and Notebook sandbox prerequisites; see the
25
+ [Linux setup requirements](https://github.com/aipoch/open-science/blob/main/docs/standalone-runtime.md#linux-prerequisites).
26
+ Python/R and an Agent runtime/model provider are configured separately during setup.
27
+ For installations that include Node itself, see the
28
+ [Homebrew and Release archive options](https://github.com/aipoch/open-science/blob/main/docs/standalone-runtime.md).
29
+
30
+ ## Get started
31
+
32
+ Start the local service and request its browser URL:
33
+
34
+ ```bash
35
+ open-science start --no-open
36
+ open-science url
37
+ ```
38
+
39
+ Open the printed URL, complete the setup checks, configure an Agent runtime and model provider,
40
+ and create your first research project. Set up a Python or R Notebook runtime if your work needs
41
+ code execution. The URL contains a local authentication token; keep it private.
42
+ For setup from the terminal, follow the
43
+ [CLI first-run guide](https://github.com/aipoch/open-science/blob/main/packages/open-science/CLI.md#first-run-setup-codex).
44
+
45
+ After setup, submit a task from the terminal:
46
+
47
+ ```bash
48
+ open-science doctor --json
49
+ open-science project create "First research" --json
50
+ open-science run --project "First research" --prompt "Draft a research plan for comparing two analysis methods." --wait --jsonl
51
+ ```
52
+
53
+ Confirm `ready: true` in the doctor output before submitting work. The CLI accepts a Project ID
54
+ or an exact, unambiguous name; SDK calls use IDs. Closing the browser does not stop the service:
55
+
56
+ ```bash
57
+ open-science status --json
58
+ open-science stop
59
+ ```
60
+
61
+ Stopping the service affects all connected clients. For automated tasks, review the
62
+ [permissions and output options](https://github.com/aipoch/open-science/blob/main/packages/open-science/CLI.md)
63
+ and the safety guidance below.
64
+
65
+ ## Update or uninstall
66
+
67
+ Stop services started by this installation before replacing it. Use the same `--config-root`
68
+ when stopping and restarting a custom profile.
69
+
70
+ ```bash
71
+ open-science stop
72
+ npm install --global @aipoch/open-science@latest
73
+ open-science start --no-open
74
+ ```
75
+
76
+ To uninstall, stop the service and run `npm uninstall --global @aipoch/open-science`.
77
+ Uninstalling the npm package retains research data and credentials. Review
78
+ [release compatibility](https://github.com/aipoch/open-science/blob/main/docs/compatibility.md)
79
+ before changing versions; replacing program files does not reverse data migrations.
4
80
 
5
81
  ## Documentation
6
82
 
7
- - [CLI guide](./CLI.md) - installation, daemon lifecycle, task automation, artifacts, and exit codes
83
+ - [CLI guide](https://github.com/aipoch/open-science/blob/main/packages/open-science/CLI.md) - installation, daemon lifecycle, task automation, artifacts, and exit codes
84
+ - [Product overview](https://github.com/aipoch/open-science#readme) - research workflows, screenshots and desktop downloads
85
+ - [Report an issue](https://github.com/aipoch/open-science/issues) - bugs and feature requests
8
86
  - [Release compatibility](https://github.com/aipoch/open-science/blob/main/docs/compatibility.md) - stable interfaces, versioning, and data upgrades
9
87
  - [Intended use](https://github.com/aipoch/open-science/blob/main/docs/usage-scope.md) and [known limitations](https://github.com/aipoch/open-science/blob/main/docs/known-limitations.md) - workflow precautions, data disclosure, and execution boundaries
10
88
 
@@ -25,19 +103,28 @@ Node.js SDK and command-line client for an Open-Science daemon running on the lo
25
103
  ## Browser-held package import
26
104
 
27
105
  Use `@aipoch/open-science/browser-import` to send a downloaded `.science` Blob to the desktop app.
28
- See the [browser integration guide](./BROWSER_IMPORT.md) for the click flow, API, security contract,
106
+ See the [browser integration guide](https://github.com/aipoch/open-science/blob/main/packages/open-science/BROWSER_IMPORT.md) for the click flow, API, security contract,
29
107
  limits, and browser requirements. The desktop never downloads the online package URL.
30
108
 
31
109
  ## SDK quick start
32
110
 
111
+ Install the package in your Node.js project (a global CLI install does not make it importable):
112
+
113
+ ```bash
114
+ npm install @aipoch/open-science
115
+ ```
116
+
117
+ Start and configure the local service as above. Save the example in an `.mjs` file and run it
118
+ with Node.js. It creates a Project and uses its ID for subsequent requests:
119
+
33
120
  ```js
34
121
  import { connectToOpenScience } from '@aipoch/open-science'
35
122
 
36
123
  const client = await connectToOpenScience()
124
+ const project = await client.createProject({ name: 'Evidence review' })
37
125
  const run = await client.startRun({
38
- project: '<project-id-or-exact-name>',
126
+ project: project.id,
39
127
  prompt: 'Summarize the evidence.',
40
- cwd: '/absolute/path/to/research',
41
128
  permissionProfile: 'auto',
42
129
  permissionPrompts: 'none' // Deny requests that would require a human; keep existing permission rules.
43
130
  })
@@ -55,8 +142,8 @@ await client.updateSessionConfiguration(run.sessionId, {
55
142
  memoryEnabled: false
56
143
  })
57
144
 
58
- const defaults = await client.getProjectSessionDefaults('systematic-review')
59
- await client.updateProjectSessionDefaults('systematic-review', {
145
+ const defaults = await client.getProjectSessionDefaults(project.id)
146
+ await client.updateProjectSessionDefaults(project.id, {
60
147
  expectedUpdatedAt: defaults.updatedAt,
61
148
  patch: { permissionProfile: 'auto' }
62
149
  })
@@ -142,7 +229,7 @@ const progress = (async () => {
142
229
  })()
143
230
 
144
231
  const observedRun = await client.startRun({
145
- project: '<project-id-or-exact-name>',
232
+ project: project.id,
146
233
  prompt: 'Summarize the evidence.',
147
234
  permissionProfile: 'auto'
148
235
  })
@@ -36,6 +36,8 @@ export function checkBrowserImportAvailability(
36
36
  export type BrowserImportTransfer = {
37
37
  /** Contains a secret; never log or persist it. Browser policy may require a fresh user click. */
38
38
  readonly url: string
39
+ /** Abort requests and timers, invalidate this transfer, and best-effort cancel the receiver. */
40
+ cancel(): Promise<void>
39
41
  /** Acknowledges receipt only, independently of destination selection and final import consent.
40
42
  * Authenticated desktop cancellation throws code 'cancelled', terminal for this transfer.
41
43
  * Keep the Blob and explicitly prepare a new transfer; never automatically retry cancellation. */
@@ -46,16 +48,20 @@ export type BrowserImportTransfer = {
46
48
  onPhase?: (phase: BrowserImportPhase) => void
47
49
  /** Acknowledged bytes only; first callback follows the first successful chunk acknowledgment. */
48
50
  onProgress?: (progress: BrowserImportProgress) => void
49
- /** Default attempts preserves the 1–5 attempt limit. wait-for-launch retries until the
51
+ /** Default attempts preserves the 1–30 attempt limit. wait-for-launch retries until the
50
52
  * overall deadline or cancellation; it observes only this transfer's authenticated handshake. */
51
53
  readinessMode?: BrowserImportReadinessMode
52
54
  /** Readiness only; defaults to 120,000 ms. Capped at 120,000 ms in wait-for-launch,
53
- * 300,000 ms in attempts mode. Timeout permits another send() on the same live transfer. */
54
- readyTimeoutMs?: number
55
- /** 1–5 attempts with 1,000 ms gaps; defaults to 5. Ignored in wait-for-launch mode. */
55
+ * 300,000 ms in attempts mode. Set null only in attempts mode to stop by attempt count
56
+ * without an overall deadline (including across page suspension). Per-request timeouts
57
+ * still apply. Timeout/exhaustion permits another send() on the same live transfer. */
58
+ readyTimeoutMs?: number | null
59
+ /** 1–30 attempts with 1,000 ms gaps; defaults to 5. Ignored in wait-for-launch mode. */
56
60
  readinessAttempts?: number
61
+ /** Delay after each failed attempt; defaults to 1,000 ms. Range: 0–30,000 ms. */
62
+ readinessIntervalMs?: number
57
63
  /** Includes response body reads; defaults to 2,000 ms. Accepts up to 30,000 ms,
58
- * clamped to 2,000 ms in wait-for-launch mode. Failed attempts have 1,000 ms gaps. */
64
+ * clamped to 2,000 ms in wait-for-launch mode. Failed attempts use readinessIntervalMs. */
59
65
  attemptTimeoutMs?: number
60
66
  }): Promise<{ received: true }>
61
67
  }
@@ -178,9 +178,33 @@ export function prepareBrowserImport(file, options = {}) {
178
178
  let sending = false
179
179
  let received = false
180
180
  let cancelled
181
+ const lifetime = new AbortController()
182
+ let cancellation
183
+ // Cancel this secret-bound receiver even while readiness is still pending.
184
+ const cancel = () => {
185
+ if (received) return Promise.resolve()
186
+ if (cancellation) return cancellation
187
+ cancelled = new BrowserImportError('cancelled', 'The transfer was cancelled.')
188
+ lifetime.abort(cancelled)
189
+ cancellation = bounded(undefined, 2000, async (signal) => {
190
+ const headers = await authorize('DELETE', '')
191
+ signal.throwIfAborted()
192
+ await fetch(base, {
193
+ method: 'DELETE',
194
+ mode: 'cors',
195
+ credentials: 'omit',
196
+ redirect: 'error',
197
+ headers,
198
+ signal,
199
+ targetAddressSpace: 'loopback'
200
+ })
201
+ }).catch(() => undefined)
202
+ return cancellation
203
+ }
181
204
 
182
205
  return Object.freeze({
183
206
  url,
207
+ cancel,
184
208
  async send({
185
209
  signal,
186
210
  onProgress,
@@ -188,18 +212,23 @@ export function prepareBrowserImport(file, options = {}) {
188
212
  readinessMode = 'attempts',
189
213
  readyTimeoutMs = 120_000,
190
214
  readinessAttempts = 5,
215
+ readinessIntervalMs = 1000,
191
216
  attemptTimeoutMs = 2000
192
217
  } = {}) {
193
218
  if (!['attempts', 'wait-for-launch'].includes(readinessMode))
194
219
  throw new Error('Invalid readiness mode.')
195
220
  const waitingForLaunch = readinessMode === 'wait-for-launch'
196
- if (!Number.isFinite(readyTimeoutMs) || readyTimeoutMs <= 0)
221
+ const countOnly = !waitingForLaunch && readyTimeoutMs === null
222
+ if (!countOnly && (!Number.isFinite(readyTimeoutMs) || readyTimeoutMs <= 0))
197
223
  throw new Error('Invalid receiver timeout.')
198
224
  if (
199
225
  (!waitingForLaunch &&
200
226
  (!Number.isInteger(readinessAttempts) ||
201
227
  readinessAttempts < 1 ||
202
- readinessAttempts > 5)) ||
228
+ readinessAttempts > 30)) ||
229
+ !Number.isFinite(readinessIntervalMs) ||
230
+ readinessIntervalMs < 0 ||
231
+ readinessIntervalMs > 30_000 ||
203
232
  !Number.isFinite(attemptTimeoutMs) ||
204
233
  attemptTimeoutMs <= 0 ||
205
234
  attemptTimeoutMs > 30_000
@@ -208,49 +237,54 @@ export function prepareBrowserImport(file, options = {}) {
208
237
  if (sending) throw new Error('This browser transfer is already running.')
209
238
  if (cancelled) throw cancelled
210
239
  if (received) return { received: true }
211
- signal?.throwIfAborted()
240
+ signal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal
241
+ signal.throwIfAborted()
212
242
  sending = true
213
243
  let verified = false
214
- const request = async (path, init = {}) => {
215
- const response = await fetch(base + path, {
216
- mode: 'cors',
217
- credentials: 'omit',
218
- cache: 'no-store',
219
- redirect: 'error',
220
- targetAddressSpace: 'loopback',
221
- ...init,
222
- signal: AbortSignal.any(
223
- [signal, init.signal ?? AbortSignal.timeout(30_000)].filter(Boolean)
224
- )
225
- })
226
- if (response.status === 410) {
227
- const value = await response.json().catch(() => {
228
- throw new BrowserImportError(
229
- 'incompatible',
230
- 'Could not verify the local receiver identity.'
231
- )
244
+ const request = async (path, init = {}) =>
245
+ bounded(signal, 30_000, async (requestSignal) => {
246
+ const response = await fetch(base + path, {
247
+ mode: 'cors',
248
+ credentials: 'omit',
249
+ cache: 'no-store',
250
+ redirect: 'error',
251
+ targetAddressSpace: 'loopback',
252
+ ...init,
253
+ signal: AbortSignal.any([requestSignal, init.signal].filter(Boolean))
232
254
  })
233
- if (value?.code !== 'cancelled' || value.proof !== (await sign('cancelled')))
234
- throw new BrowserImportError(
235
- 'incompatible',
236
- 'Could not verify the local receiver identity.'
255
+ const value = await response.json().catch((error) => {
256
+ if (response.status === 410) {
257
+ throw new BrowserImportError(
258
+ 'incompatible',
259
+ 'Could not verify the local receiver identity.'
260
+ )
261
+ }
262
+ throw error
263
+ })
264
+ if (response.status === 410) {
265
+ if (value?.code !== 'cancelled' || value.proof !== (await sign('cancelled')))
266
+ throw new BrowserImportError(
267
+ 'incompatible',
268
+ 'Could not verify the local receiver identity.'
269
+ )
270
+ cancelled = new BrowserImportError(
271
+ 'cancelled',
272
+ 'The transfer was cancelled in Open-Science. Prepare a new transfer to try again.'
237
273
  )
238
- cancelled = new BrowserImportError(
239
- 'cancelled',
240
- 'The transfer was cancelled in Open-Science. Prepare a new transfer to try again.'
241
- )
242
- throw cancelled
243
- }
244
- return response
245
- }
274
+ throw cancelled
275
+ }
276
+ return { ok: response.ok, status: response.status, value }
277
+ })
246
278
  try {
247
279
  onPhase?.('waiting-for-receiver')
248
- // Browser protocol prompts have no observable completion event. Only this secret's proof
249
- // ends launch waiting; the bounded deadline covers requests, body reads and retry gaps.
250
- const budget = Math.min(readyTimeoutMs, waitingForLaunch ? 120_000 : 300_000)
280
+ // Count-only polling survives page suspension without consuming an overall deadline.
281
+ // Numeric/default budgets retain the legacy deadline across requests, bodies and gaps.
282
+ const budget = countOnly
283
+ ? null
284
+ : Math.min(readyTimeoutMs, waitingForLaunch ? 120_000 : 300_000)
251
285
  const attemptBudget = waitingForLaunch ? Math.min(attemptTimeoutMs, 2000) : attemptTimeoutMs
252
- const deadline = Date.now() + budget
253
- const ready = await bounded(signal, budget, async (readySignal) => {
286
+ const deadline = budget === null ? null : Date.now() + budget
287
+ const poll = async (readySignal) => {
254
288
  for (let attempt = 0; waitingForLaunch || attempt < readinessAttempts; attempt++) {
255
289
  readySignal.throwIfAborted()
256
290
  try {
@@ -259,7 +293,7 @@ export function prepareBrowserImport(file, options = {}) {
259
293
  const response = await request(`/ready?challenge=${challenge}`, {
260
294
  signal: attemptSignal
261
295
  })
262
- const value = await response.json()
296
+ const value = response.value
263
297
  if (
264
298
  !value ||
265
299
  typeof value !== 'object' ||
@@ -277,7 +311,7 @@ export function prepareBrowserImport(file, options = {}) {
277
311
  `open-science-import-v1:${challenge}:${value.offset}:${value.accepted ? 1 : 0}`
278
312
  )
279
313
  attemptSignal.throwIfAborted()
280
- if (Date.now() >= deadline)
314
+ if (deadline !== null && Date.now() >= deadline)
281
315
  throw new BrowserImportError(
282
316
  'timeout',
283
317
  'Open-Science did not become ready in time.'
@@ -297,14 +331,16 @@ export function prepareBrowserImport(file, options = {}) {
297
331
  'incompatible',
298
332
  'Could not verify the local receiver identity.'
299
333
  )
300
- if (waitingForLaunch || attempt + 1 < readinessAttempts) await wait(1000, readySignal)
334
+ if (waitingForLaunch || attempt + 1 < readinessAttempts)
335
+ await wait(readinessIntervalMs, readySignal)
301
336
  }
302
337
  }
303
338
  throw new BrowserImportError(
304
339
  'readiness-exhausted',
305
340
  'Open-Science did not become ready. Open the app, allow local network access, and retry.'
306
341
  )
307
- })
342
+ }
343
+ const ready = budget === null ? await poll(signal) : await bounded(signal, budget, poll)
308
344
  verified = true
309
345
  if (
310
346
  !Number.isSafeInteger(ready.offset) ||
@@ -338,7 +374,7 @@ export function prepareBrowserImport(file, options = {}) {
338
374
  }
339
375
  })
340
376
  if (!chunk.ok) throw new Error(`Local package transfer failed (${chunk.status}).`)
341
- const result = await chunk.json()
377
+ const result = chunk.value
342
378
  if (
343
379
  result.offset !== offset + bytes.byteLength ||
344
380
  result.proof !== (await sign(`offset:${result.offset}`))
@@ -351,7 +387,7 @@ export function prepareBrowserImport(file, options = {}) {
351
387
  method: 'POST',
352
388
  headers: await authorize('POST', '/complete')
353
389
  })
354
- const receipt = await complete.json()
390
+ const receipt = complete.value
355
391
  if (
356
392
  complete.status !== 202 ||
357
393
  receipt.received !== true ||
@@ -362,17 +398,7 @@ export function prepareBrowserImport(file, options = {}) {
362
398
  received = true
363
399
  return { received: true }
364
400
  } catch (error) {
365
- if (verified && signal?.aborted) {
366
- await fetch(base, {
367
- method: 'DELETE',
368
- mode: 'cors',
369
- credentials: 'omit',
370
- redirect: 'error',
371
- headers: await authorize('DELETE', ''),
372
- signal: AbortSignal.timeout(2000),
373
- targetAddressSpace: 'loopback'
374
- }).catch(() => undefined)
375
- }
401
+ if (signal?.aborted) await cancel()
376
402
  signal?.throwIfAborted()
377
403
  if (error.code === 'cancelled' || error.code === 'incompatible') throw error
378
404
  if (verified)
package/cli.mjs CHANGED
@@ -5,14 +5,19 @@
5
5
  import { randomUUID } from 'node:crypto'
6
6
  import { closeSync, createWriteStream, existsSync, openSync, realpathSync } from 'node:fs'
7
7
  import { chmod, lstat, mkdir, readFile, readlink, rename, rm, stat } from 'node:fs/promises'
8
- import { dirname, join, resolve } from 'node:path'
8
+ import { basename, dirname, join, resolve } from 'node:path'
9
9
  import { Readable } from 'node:stream'
10
10
  import { pipeline } from 'node:stream/promises'
11
11
  import { text as readStreamText } from 'node:stream/consumers'
12
12
  import { spawn } from 'node:child_process'
13
13
  import { fileURLToPath } from 'node:url'
14
14
 
15
- import { findServiceState, readWebToken, resolveConfigRoot } from './config-root.mjs'
15
+ import {
16
+ candidateConfigRoots,
17
+ findServiceState,
18
+ readWebToken,
19
+ resolveConfigRoot
20
+ } from './config-root.mjs'
16
21
  import { codexLoginCommand } from './codex-login.mjs'
17
22
  import { connectToOpenScience, OpenScienceApiError } from './index.mjs'
18
23
  import { locateBackend } from './locate-backend.mjs'
@@ -27,6 +32,9 @@ const MAX_DOWNLOAD_SYMLINK_HOPS = 40
27
32
  const usage = `Usage: open-science <command> [options]
28
33
 
29
34
  Commands:
35
+ version Print the local application version (also --version or -v)
36
+ help [command] Show general or command help
37
+ paths Show local CLI, Node and configuration discovery paths
30
38
  init Create the local CLI configuration directory
31
39
  start Start the headless backend and localhost web UI
32
40
  stop Gracefully stop the backend
@@ -54,6 +62,8 @@ Commands:
54
62
  session status <session-id>
55
63
  session config show <session-id>
56
64
  session config update <session-id> --revision <number> [session options]
65
+ session export <session-id> --project <id-or-name> --output <path>
66
+ session import <path> [--project <id-or-name> | --new-project <name>] --non-interactive
57
67
  settings agent-routing show
58
68
  settings agent-routing update [routing options]
59
69
  plan show <session-id>
@@ -101,14 +111,17 @@ Options:
101
111
  --wait Wait for the run to finish
102
112
  --return-on-attention With --wait, return when the Plan needs approval
103
113
  --timeout-ms <ms> Stop waiting after this many milliseconds
114
+ --output <path> Destination path (session export, artifacts download)
115
+ --new-project <name> Import into a new Project with this name (session import)
116
+ --non-interactive Import without an interactive preview confirmation (session import)
104
117
  --cancel-on-timeout Cancel the server run when --timeout-ms expires
105
118
  --jsonl With run --wait, stream one machine-readable event per line
106
- --output <path> Artifact download destination
107
119
  --credential-store <os|file> Settings credential storage (Linux headless; start only)
108
120
  --no-open Do not open the browser after start
109
121
  --no-sandbox Disable Chromium's process sandbox (security risk; start only)
110
122
  --force Sign in again even when Codex credentials already exist
111
123
  --json Emit one machine-readable result
124
+ -v, --version Print the local application version and exit
112
125
  -h, --help Show this help`
113
126
 
114
127
  // Flags that take a value, mapped to their camelCase option key (explicit so new hyphenated flags
@@ -151,7 +164,8 @@ const VALUE_OPTIONS = {
151
164
  '--description': 'description',
152
165
  '--agent-context': 'agentContext',
153
166
  '--agent-context-file': 'agentContextFile',
154
- '--output': 'output'
167
+ '--output': 'output',
168
+ '--new-project': 'newProject'
155
169
  }
156
170
 
157
171
  const TASK_COMMANDS = new Set([
@@ -186,6 +200,9 @@ const GROUP_COMMANDS = new Set([
186
200
  // Project create, update, and session-defaults intentionally remain unbounded because their
187
201
  // positional Project names may contain multiple unquoted words.
188
202
  const POSITIONAL_LIMITS = new Map([
203
+ ['version', 0],
204
+ ['help', 1],
205
+ ['paths', 0],
189
206
  ['doctor', 0],
190
207
  ['runtime list', 0],
191
208
  ['runtime install', 1],
@@ -216,6 +233,8 @@ const POSITIONAL_LIMITS = new Map([
216
233
  ['run cancel', 1],
217
234
  ['session status', 1],
218
235
  ['session config', 2],
236
+ ['session export', 1],
237
+ ['session import', 1],
219
238
  ['settings agent-routing', 1],
220
239
  ['plan show', 1],
221
240
  ['plan approve', 1],
@@ -255,7 +274,17 @@ const assertPositionalLimit = (command, subcommand, positionals) => {
255
274
 
256
275
  export const parseCliArgs = (argv) => {
257
276
  const args = [...argv]
258
- const command = args.shift()
277
+ const requestedCommand = args.shift()
278
+ const command =
279
+ requestedCommand === '--version' || requestedCommand === '-v' ? 'version' : requestedCommand
280
+ const localOptions =
281
+ command === 'version'
282
+ ? ['--json', '--help', '-h']
283
+ : command === 'paths'
284
+ ? ['--json', '--help', '-h', '--config-root', '--profile']
285
+ : command === 'help'
286
+ ? ['--help', '-h']
287
+ : undefined
259
288
  const subcommand =
260
289
  (GROUP_COMMANDS.has(command) && args[0] && !args[0].startsWith('-')) ||
261
290
  (command === 'run' && (args[0] === 'status' || args[0] === 'cancel'))
@@ -270,6 +299,9 @@ export const parseCliArgs = (argv) => {
270
299
  const positionals = []
271
300
  while (args.length > 0) {
272
301
  const arg = args.shift()
302
+ if (localOptions && arg.startsWith('-') && !localOptions.includes(arg)) {
303
+ throw new CliUsageError(`Unknown option for ${command}: ${arg}`)
304
+ }
273
305
  if (arg.startsWith('--credential-store=')) {
274
306
  if (options.credentialStore !== undefined)
275
307
  throw new CliUsageError('Specify --credential-store only once.')
@@ -284,6 +316,7 @@ export const parseCliArgs = (argv) => {
284
316
  else if (arg === '--wait') options.wait = true
285
317
  else if (arg === '--return-on-attention') options.returnOnAttention = true
286
318
  else if (arg === '--plan-first') options.planFirst = true
319
+ else if (arg === '--non-interactive') options.nonInteractive = true
287
320
  else if (arg === '--provider-default-model') options.providerDefaultModel = true
288
321
  else if (arg === '--clear-compute-hosts') options.clearComputeHosts = true
289
322
  else if (arg === '--memory') {
@@ -347,6 +380,10 @@ export const parseCliArgs = (argv) => {
347
380
  ...(positionals.length ? { positionals } : {}),
348
381
  options
349
382
  }
383
+ if (localOptions) {
384
+ assertPositionalLimit(command, subcommand, positionals)
385
+ return parsed
386
+ }
350
387
  // Help is local: it must not require execution-only options or a running backend.
351
388
  if (options.help) return parsed
352
389
  if (options.port !== undefined) {
@@ -435,8 +472,18 @@ export const parseCliArgs = (argv) => {
435
472
  if (options.jsonl && (command !== 'run' || subcommand || !options.wait)) {
436
473
  throw new CliUsageError('--jsonl requires run --wait.')
437
474
  }
438
- if (options.timeoutMs !== undefined && (command !== 'run' || subcommand || !options.wait)) {
439
- throw new CliUsageError('--timeout-ms requires run --wait.')
475
+ const isPackageTransfer = command === 'session' && ['export', 'import'].includes(subcommand)
476
+ if (
477
+ options.timeoutMs !== undefined &&
478
+ !isPackageTransfer &&
479
+ (command !== 'run' || subcommand || !options.wait)
480
+ ) {
481
+ throw new CliUsageError('--timeout-ms requires run --wait or session export/import.')
482
+ }
483
+ if (command === 'session' && subcommand === 'import') {
484
+ if (!options.nonInteractive) throw new CliUsageError('--non-interactive is required.')
485
+ if (options.project && options.newProject)
486
+ throw new CliUsageError('Use only one of --project and --new-project.')
440
487
  }
441
488
  if (options.returnOnAttention && (command !== 'run' || subcommand || !options.wait)) {
442
489
  throw new CliUsageError('--return-on-attention requires run --wait.')
@@ -1514,6 +1561,40 @@ export const runTaskCommand = async (parsed, dependencies = {}) => {
1514
1561
  )
1515
1562
  return
1516
1563
  }
1564
+ if (command === 'session' && subcommand === 'export') {
1565
+ const sessionId = positionals[0]
1566
+ if (!sessionId) throw new CliUsageError('Session id is required.')
1567
+ if (!options.project) throw new CliUsageError('--project is required.')
1568
+ if (!options.output) throw new CliUsageError('--output is required.')
1569
+ const projectId = await resolveCliProjectId(client, options.project)
1570
+ outputValue(
1571
+ await client.exportSessionPackage(projectId, sessionId, resolve(options.output), {
1572
+ timeoutMs: options.timeoutMs
1573
+ }),
1574
+ options,
1575
+ deps
1576
+ )
1577
+ return
1578
+ }
1579
+ if (command === 'session' && subcommand === 'import') {
1580
+ const packagePath = positionals[0]
1581
+ if (!packagePath) throw new CliUsageError('Session package path is required.')
1582
+ const resolvedPath = resolve(packagePath)
1583
+ const target = {}
1584
+ if (options.project) target.projectId = await resolveCliProjectId(client, options.project)
1585
+ else if (options.newProject) target.projectName = options.newProject
1586
+ outputValue(
1587
+ await client.importSessionPackage(
1588
+ target,
1589
+ resolvedPath,
1590
+ { nonInteractive: true },
1591
+ { timeoutMs: options.timeoutMs }
1592
+ ),
1593
+ options,
1594
+ deps
1595
+ )
1596
+ return
1597
+ }
1517
1598
  if (command === 'settings' && subcommand === 'agent-routing') {
1518
1599
  const action = positionals[0]
1519
1600
  if (action === 'show') {
@@ -1749,14 +1830,55 @@ ${commands.join('\n')}
1749
1830
  Run "open-science --help" for the complete option reference.`
1750
1831
  }
1751
1832
 
1833
+ const readCliMetadata = async () => {
1834
+ const here = dirname(fileURLToPath(import.meta.url))
1835
+ // Source SDK metadata has its own version; installed CLI manifests carry the release version.
1836
+ const development =
1837
+ basename(here) === 'open-science' &&
1838
+ basename(dirname(here)) === 'packages' &&
1839
+ existsSync(join(here, '../../src/main/node-entry.ts'))
1840
+ const manifestPath = join(development ? join(here, '../..') : here, 'package.json')
1841
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
1842
+ if (
1843
+ manifest.name !== (development ? 'open-science' : '@aipoch/open-science') ||
1844
+ typeof manifest.version !== 'string' ||
1845
+ !/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(manifest.version)
1846
+ ) {
1847
+ throw new Error(`Invalid Open-Science CLI metadata: ${manifestPath}`)
1848
+ }
1849
+ return { version: manifest.version, development }
1850
+ }
1851
+
1852
+ const pathsCommand = async (options) => {
1853
+ const { development } = await readCliMetadata()
1854
+ const paths = {
1855
+ cliPath: fileURLToPath(import.meta.url),
1856
+ nodePath: process.execPath,
1857
+ startupConfigRoot: resolveConfigRoot({
1858
+ packaged: !development,
1859
+ override: options.configRoot
1860
+ }),
1861
+ discoveryConfigRoots: candidateConfigRoots({ override: options.configRoot })
1862
+ }
1863
+ console.log(
1864
+ options.json
1865
+ ? JSON.stringify(paths)
1866
+ : `CLI: ${paths.cliPath}\nNode: ${paths.nodePath}\nStartup configuration: ${paths.startupConfigRoot}\nService discovery (in order):\n${paths.discoveryConfigRoots.map((root) => ` ${root}`).join('\n')}`
1867
+ )
1868
+ }
1869
+
1752
1870
  export const runCli = async (argv = process.argv.slice(2)) => {
1753
1871
  const parsed = parseCliArgs(argv)
1754
1872
  const { command, options } = parsed
1755
- if (options.help || !command || command === '-h' || command === '--help') {
1756
- console.log(commandHelp(command))
1873
+ if (options.help || !command || command === 'help' || command === '-h' || command === '--help') {
1874
+ console.log(commandHelp(command === 'help' ? parsed.positionals?.[0] : command))
1757
1875
  return
1758
1876
  }
1759
- if (command === 'init') await initCommand(options)
1877
+ if (command === 'version') {
1878
+ const { version } = await readCliMetadata()
1879
+ console.log(options.json ? JSON.stringify({ version }) : version)
1880
+ } else if (command === 'paths') await pathsCommand(options)
1881
+ else if (command === 'init') await initCommand(options)
1760
1882
  else if (command === 'start') await startCommand(options)
1761
1883
  else if (command === 'stop') await stopCommand(options)
1762
1884
  else if (command === 'status') await statusCommand(options)
package/index.d.ts CHANGED
@@ -99,6 +99,7 @@ export type ConnectorsSnapshot = {
99
99
  ncbi: NcbiCredentialsView
100
100
  // Optional only for compatibility with an older main process during local development.
101
101
  openAlex?: OpenAlexCredentialView
102
+ semanticScholar?: { hasApiKey: boolean }
102
103
  }
103
104
 
104
105
  export type DeviceCredentialKind = 'api_key' | 'token' | 'oauth'
package/index.mjs CHANGED
@@ -6,6 +6,7 @@ const defaultSleep = (milliseconds) =>
6
6
  new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds))
7
7
 
8
8
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
9
+ const PACKAGE_REQUEST_TIMEOUT_MS = 60 * 60 * 1_000
9
10
  const DEFAULT_EVENT_IDLE_TIMEOUT_MS = 30_000
10
11
  const EVENT_RECONNECT_DELAY_MS = 100
11
12
  const MAX_BUFFERED_EVENTS = 1_024
@@ -308,6 +309,54 @@ export class OpenScienceClient {
308
309
  return this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/config`, options)
309
310
  }
310
311
 
312
+ // Web RPC seam: authenticated loopback dispatch of one application command channel.
313
+ // The CLI reaches headless-only commands (Session package export/import) this way.
314
+ async callRpc(channel, args, options = {}) {
315
+ return withRequestTimeout(
316
+ {
317
+ defaultTimeoutMs: this.requestTimeoutMs,
318
+ signal: options.signal,
319
+ timeoutMs: options.timeoutMs
320
+ },
321
+ async (signal) => {
322
+ const response = await this.fetch(`${this.baseUrl}/rpc/${encodeURIComponent(channel)}`, {
323
+ method: 'POST',
324
+ headers: {
325
+ authorization: `Bearer ${this.token}`,
326
+ accept: 'application/json',
327
+ 'content-type': 'application/json'
328
+ },
329
+ body: JSON.stringify({ protocolVersion: 1, args }),
330
+ signal
331
+ })
332
+ const payload = await response.json().catch(() => undefined)
333
+ if (!response.ok || payload?.ok !== true) {
334
+ throw new OpenScienceApiError(
335
+ payload?.error?.message ?? `Open-Science Web RPC request failed (${response.status}).`,
336
+ { code: payload?.error?.code, status: response.status }
337
+ )
338
+ }
339
+ return payload.result
340
+ }
341
+ )
342
+ }
343
+
344
+ exportSessionPackage(projectId, sessionId, destinationPath, options) {
345
+ return this.callRpc('sessions:export-package', [{ projectId, sessionId }, destinationPath], {
346
+ ...options,
347
+ timeoutMs: options?.timeoutMs ?? PACKAGE_REQUEST_TIMEOUT_MS
348
+ })
349
+ }
350
+
351
+ importSessionPackage(target, sourcePath, options, rpcOptions) {
352
+ if (options?.nonInteractive !== true)
353
+ throw new TypeError('Non-interactive Session import requires explicit confirmation.')
354
+ return this.callRpc('sessions:import-package', [target, sourcePath, { nonInteractive: true }], {
355
+ ...rpcOptions,
356
+ timeoutMs: rpcOptions?.timeoutMs ?? PACKAGE_REQUEST_TIMEOUT_MS
357
+ })
358
+ }
359
+
311
360
  updateSessionConfiguration(sessionId, request, options) {
312
361
  return this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/config`, {
313
362
  ...options,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aipoch/open-science",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Node.js SDK and CLI for the Open-Science local daemon",
5
5
  "type": "module",
6
6
  "exports": {
@@ -38,10 +38,10 @@
38
38
  "access": "public"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@aipoch/open-science-darwin-arm64": "1.0.1",
42
- "@aipoch/open-science-darwin-x64": "1.0.1",
43
- "@aipoch/open-science-linux-x64-gnu": "1.0.1",
44
- "@aipoch/open-science-linux-arm64-gnu": "1.0.1",
45
- "@aipoch/open-science-win32-x64": "1.0.1"
41
+ "@aipoch/open-science-darwin-arm64": "1.0.3",
42
+ "@aipoch/open-science-darwin-x64": "1.0.3",
43
+ "@aipoch/open-science-linux-x64-gnu": "1.0.3",
44
+ "@aipoch/open-science-linux-arm64-gnu": "1.0.3",
45
+ "@aipoch/open-science-win32-x64": "1.0.3"
46
46
  }
47
47
  }