@ours.network/install 0.18.0-nightly.4 → 0.18.0-nightly.6

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/README.md CHANGED
@@ -9,6 +9,21 @@ npm install --global @ours.network/install
9
9
  ours-install
10
10
  ```
11
11
 
12
+ ### Stable and nightly channels
13
+
14
+ Installing `@ours.network/install@latest` selects the stable channel; installing
15
+ `@ours.network/install@nightly` selects the nightly channel. Before changing the
16
+ machine, the installer resolves `@ours.network/mcp`,
17
+ `@ours.network/claude-code`, and `@ours.network/codex`, verifies that their
18
+ selected dist-tags expose one exact lockstep version, and fails closed if they
19
+ do not. MCP and the Codex launcher are installed by exact version, and local
20
+ Claude Code and Codex marketplace manifests pin the corresponding plugin
21
+ packages to that same version.
22
+
23
+ `OURS_CHANNEL=latest|nightly` (or the legacy `OURS_INSTALL_CHANNEL`) remains an
24
+ explicit override. Without an override, the installed package's own version
25
+ selects the channel.
26
+
12
27
  The normal flow uses one daemon at `~/.ours` on port 3050, shows an eight-stage
13
28
  progress bar, and asks only for information it cannot safely infer (normally the
14
29
  Human identity's display name). Existing daemon conflicts and moving a Telegram
@@ -24,8 +39,8 @@ connector from another daemon still require explicit confirmation.
24
39
  or preserves the existing one on a re-run.
25
40
  - Installs the ours plugin into safely detected Claude Code, Codex, and Hermes
26
41
  installations.
27
- - Configures and starts cowork against the shared daemon.
28
- - Configures Telegram against the same daemon, but does **not** start it.
42
+ - Configures and starts cowork as a durable shim over the shared daemon.
43
+ - Configures and starts Telegram as a durable shim over the same daemon.
29
44
  - Runs Fleet's host initialization and, when `~/fleet.yaml` is absent, writes a
30
45
  conservative stopped starter with `FleetCoordinator`, a `fleet-health`
31
46
  watchdog, and a ten-minute `coordinator_health` loop. An existing
@@ -35,20 +50,21 @@ The operator CLI owns daemon configuration, lifecycle, and boot persistence.
35
50
  The MCP package is only the stdio adapter spawned by agent harnesses; the
36
51
  installer never asks `ours-mcp` to start a daemon.
37
52
 
38
- The external-history storage epoch is a clean breaking reset. The daemon refuses
39
- old packet state without modifying it, and this installer never migrates, purges,
40
- or silently replaces identities, contacts, invites, pending payloads, or history.
41
- Back up any wanted old state and remove it explicitly before starting the new epoch.
53
+ Daemon state is temporarily scoped to its package major version. On a same-major
54
+ update, the installer refreshes the packages and runs `ours daemon restart`; the
55
+ CLI streams structured startup phases until restore is complete instead of
56
+ appearing to hang. A different-major update is detected before package
57
+ replacement. The installer explains the incompatibility and, only in an
58
+ interactive run, offers to stop the CLI-managed daemon, copy the complete state
59
+ directory to a timestamped directory under `~/.ours-backups/`, remove the
60
+ managed service and old state, then initialize the new major. The default answer is no, and
61
+ `OURS_ASSUME_YES` never authorizes this purge.
42
62
 
43
63
  ## What remains stopped
44
64
 
45
- Telegram and Fleet are installed but intentionally not started. Review and
46
- activate them when ready:
65
+ Only Fleet is intentionally not started. Review and activate it when ready:
47
66
 
48
67
  ```sh
49
- # After configuring a Telegram bot and route locally:
50
- ours-tg-connector install-service
51
-
52
68
  # After reviewing ~/fleet.yaml:
53
69
  ours-fleet doctor
54
70
  ours-fleet config
@@ -74,8 +90,11 @@ new Human identity and asks no ordinary setup questions, but it never bypasses
74
90
  selection conflicts, connector moves, or destructive safeguards.
75
91
 
76
92
  A non-default daemon must be selected coherently with a config file or matching
77
- port and state directory. Harnesses and the generated Fleet role receive that
78
- selection through `OURS_CONFIG`; there is no per-application daemon.
93
+ port and state directory. Hermes and the generated Fleet role persist that
94
+ selection through `OURS_CONFIG`. Claude Code and Codex plugin registrations
95
+ cannot store an environment value; for those harnesses the installer prints the
96
+ exact `export OURS_CONFIG=...` line that must be added to the shell profile
97
+ before starting the harness. There is no per-application daemon.
79
98
 
80
99
  ## Uninstall
81
100
 
@@ -92,6 +111,8 @@ targets only the explicit state directory.
92
111
 
93
112
  `OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) selects the packages' nightly
94
113
  dist-tags. Without an override, the installer's own version selects the channel.
114
+ The operator CLI intentionally has no nightly dist-tag and remains untagged on
115
+ both channels.
95
116
 
96
117
  ## Environment
97
118
 
package/install.sh CHANGED
@@ -21,6 +21,9 @@
21
21
  # From a CLONE (a sibling install.mjs is present) it runs that directly. Piped as `curl … | bash`
22
22
  # it installs the published command globally — `npm i -g @ours.network/install` — then runs
23
23
  # `ours-install`. Idempotent: a re-run updates to @latest and runs again.
24
+ # The bootstrap is deliberately stable. For nightly, install the published nightly command
25
+ # directly: `npm i -g @ours.network/install@nightly && ours-install`; that package's own
26
+ # X.Y.Z-nightly.N version selects and exactly resolves the matching stack channel.
24
27
  #
25
28
  # Non-interactive env overrides (all optional) — consumed by the Node installer:
26
29
  # OURS_ASSUME_YES=1 accept every default; never prompt (no tty needed)
@@ -1,7 +1,7 @@
1
1
  // ours-install v3 — component selection and attachment.
2
2
  //
3
- // Spec: installer-spec-v3 §5 (and the repoint half of §7, which cannot be
4
- // separated from attaching the connector without making a silent move possible).
3
+ // Component selection and attachment planning. Repoint decisions stay beside
4
+ // attachment decisions so an existing connector can never move silently.
5
5
  // Pure, like target.mjs and plan.mjs: the caller injects the current file
6
6
  // contents and the installed versions, and every function returns a plan.
7
7
  //
@@ -101,7 +101,7 @@ export const tgConfigPath = (home, env = {}) => env.OURS_TG_CONFIG ?? join(home,
101
101
  export const coworkConfigPath = (home, env = {}) => env.OURS_COWORK_CONFIG ?? join(home, '.ours-cowork', 'config.json');
102
102
 
103
103
  // -----------------------------------------------------------------------------
104
- // §5 selection
104
+ // Component selection
105
105
  // -----------------------------------------------------------------------------
106
106
 
107
107
  /**
@@ -133,7 +133,7 @@ export function planComponentSelection({ answers = {}, installed = {}, assumeYes
133
133
  }
134
134
 
135
135
  // -----------------------------------------------------------------------------
136
- // §5 — the MCP server
136
+ // MCP server attachment
137
137
  // -----------------------------------------------------------------------------
138
138
 
139
139
  /**
@@ -159,7 +159,7 @@ export function planMcpAttachment({ stateDir, isDefaultStateDir, channel = 'late
159
159
  }
160
160
 
161
161
  // -----------------------------------------------------------------------------
162
- // §5 / §7 the Telegram connector
162
+ // Telegram connector attachment and repointing
163
163
  // -----------------------------------------------------------------------------
164
164
 
165
165
  /**
@@ -203,11 +203,10 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
203
203
  changed: changes.length > 0,
204
204
  changes,
205
205
  config: merged,
206
- // Telegram is intentionally staged but stopped. Its CLI currently couples
207
- // service installation with `enable --now`, so calling it here would violate
208
- // the installer's promise not to launch Telegram before a bot is configured.
209
- // The end screen gives this exact command as the explicit opt-in start step.
210
- service: null,
206
+ // The connector is a durable shim over the shared daemon. Its service command
207
+ // owns the platform-specific unit and `enable --now`; the installer invokes
208
+ // it after committing the coherent daemon selection below.
209
+ service: ['ours-tg-connector', 'install-service'],
211
210
  untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
212
211
  };
213
212
  if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
@@ -218,12 +217,12 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
218
217
  to: { daemonUrl: endpoint, daemonStateDir: dir },
219
218
  prompt: `The Telegram connector currently uses ${current.daemonUrl ?? 'an unrecorded daemon'} (${current.daemonStateDir ?? 'unrecorded state directory'}).\nPoint it at ${endpoint} (${dir}) instead? This MOVES the connector; it does not add a second one.`,
220
219
  };
221
- // Never repointed without a human, in any mode (spec §9).
220
+ // Never repoint an existing connector without a human, in any mode.
222
221
  return assumeYes ? { ...repoint, action: 'skip-repoint', reason: 'never repointed non-interactively' } : repoint;
223
222
  }
224
223
 
225
224
  // -----------------------------------------------------------------------------
226
- // §5 — cowork
225
+ // cowork attachment
227
226
  // -----------------------------------------------------------------------------
228
227
 
229
228
  /**
@@ -342,7 +341,7 @@ export function planCoworkAttachment({ existing, endpoint, stateDir, installedVe
342
341
  }
343
342
 
344
343
  // -----------------------------------------------------------------------------
345
- // §5 one component failing does not stop the others
344
+ // Component failures are isolated so the remaining components can continue.
346
345
  // -----------------------------------------------------------------------------
347
346
 
348
347
  /**
package/lib/detect.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // ours-install v3 — which daemons are already on this machine, and which one this
2
2
  // run is for.
3
3
  //
4
- // Never ask for a state directory or a port to be TYPED (spec §2). When several
4
+ // Never ask for a state directory or port to be typed. When several
5
5
  // daemons are DETECTED, show them and let one be picked: selecting from what was
6
6
  // found is not prompting for a path.
7
7
  //
@@ -91,7 +91,7 @@ export function detectDaemons({ candidates = [], exists, readJson }) {
91
91
  /**
92
92
  * A state directory for a daemon this run would CREATE, derived and never typed.
93
93
  *
94
- * Spec §2 forbids asking for a path, so "create a new one" has to derive somewhere
94
+ * The UI does not ask for a path, so "create a new one" has to derive somewhere
95
95
  * to put it: `~/.ours` when free, else the first free `~/.ours-2`, `~/.ours-3`…
96
96
  * An operator who wants a specific path still has `--state-dir`, which bypasses
97
97
  * this screen entirely.
@@ -151,7 +151,7 @@ export function planDaemonSelection({
151
151
  *
152
152
  * Deliberately strict: an answer that is not a number in range, or the create
153
153
  * option, is NOT a state directory to be interpreted. Accepting free text here
154
- * would be exactly the "type a path" prompt spec §2 forbids, arriving through the
154
+ * would reintroduce the forbidden "type a path" prompt through the
155
155
  * back door.
156
156
  */
157
157
  export function resolveSelection(answer, { candidates = [], createOption = null } = {}) {
package/lib/effects.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  // unit file or the service manager directly.
13
13
 
14
14
  import { spawnSync, execFileSync } from 'node:child_process';
15
- import { existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { cpSync, existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
17
17
  import { dirname, join, resolve } from 'node:path';
18
18
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
@@ -29,7 +29,12 @@ async function probePort(port, { timeoutMs = 1500 } = {}) {
29
29
  if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
30
30
  const body = await res.json();
31
31
  if (typeof body?.stateDir !== 'string') return { ok: false, reason: 'no stateDir in reply' };
32
- return { ok: true, stateDir: body.stateDir };
32
+ return {
33
+ ok: true,
34
+ stateDir: body.stateDir,
35
+ version: typeof body.version === 'string' ? body.version : null,
36
+ compat: Number.isInteger(body.compat) ? body.compat : null,
37
+ };
33
38
  } catch (error) {
34
39
  return { ok: false, reason: error?.name === 'AbortError' ? 'timed out' : String(error?.message ?? error) };
35
40
  } finally {
@@ -64,9 +69,9 @@ function readTextFile(path) {
64
69
  }
65
70
  }
66
71
 
67
- function installedVersionOf(pkg) {
72
+ function installedVersionOf(pkg, npmBin = 'npm') {
68
73
  try {
69
- const out = execFileSync('npm', ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
74
+ const out = execFileSync(npmBin, ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
70
75
  return JSON.parse(out)?.dependencies?.[pkg]?.version ?? null;
71
76
  } catch {
72
77
  // Unreadable is NOT "new enough": the cowork gate fails closed on null.
@@ -74,6 +79,44 @@ function installedVersionOf(pkg) {
74
79
  }
75
80
  }
76
81
 
82
+ function packageDependenciesOf(pkgSpec, npmBin = 'npm') {
83
+ const probe = capture(npmBin, ['view', pkgSpec, 'dependencies', '--json'], { timeout: 15_000 });
84
+ if (!probe.ok) return null;
85
+ try {
86
+ const parsed = JSON.parse(probe.stdout);
87
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function resolvePackageVersion(pkg, channel, npmBin = 'npm') {
94
+ const tag = channel === 'nightly' ? 'nightly' : 'latest';
95
+ const probe = capture(npmBin, ['view', `${pkg}@${tag}`, 'version', '--json'], { timeout: 15_000 });
96
+ if (!probe.ok) return '';
97
+ try {
98
+ const parsed = JSON.parse(probe.stdout);
99
+ return typeof parsed === 'string' ? parsed : '';
100
+ } catch {
101
+ return /^\S+$/.test(probe.stdout.trim()) ? probe.stdout.trim() : '';
102
+ }
103
+ }
104
+
105
+ function codexMarketplace() {
106
+ const probe = capture('codex', ['plugin', 'marketplace', 'list', '--json'], { timeout: 6_000 });
107
+ if (!probe.ok) return null;
108
+ try {
109
+ return JSON.parse(probe.stdout)?.marketplaces?.find((m) => m?.name === 'ours-codex-marketplace') ?? null;
110
+ } catch { return null; }
111
+ }
112
+
113
+ function hasClaudePlugin() {
114
+ const probe = capture('claude', ['plugin', 'list', '--json'], { timeout: 6_000 });
115
+ if (!probe.ok) return false;
116
+ try { return JSON.parse(probe.stdout)?.some((p) => p?.id === 'ours@ours.network') ?? false; }
117
+ catch { return false; }
118
+ }
119
+
77
120
  /**
78
121
  * A read-only command probe that NEVER throws and NEVER inherits stdio.
79
122
  *
@@ -207,6 +250,7 @@ function knownStateDirsIn(home) {
207
250
  * the orchestrator never reaches for a terminal itself.
208
251
  */
209
252
  export function realEffects({ write, ttyFd, env = process.env, home = homedir(), out, version = null } = {}) {
253
+ const npmBin = env.OURS_NPM?.trim() || 'npm';
210
254
  return {
211
255
  home,
212
256
  env,
@@ -221,6 +265,15 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
221
265
  // pure planner already gated four ways, and this deletes exactly that.
222
266
  removeDir: (path) => { rmSync(resolve(path), { recursive: true, force: true }); },
223
267
  removeFile: (path) => { rmSync(resolve(path), { force: true }); },
268
+ copyDir: (source, destination) => {
269
+ mkdirSync(dirname(resolve(destination)), { recursive: true, mode: 0o700 });
270
+ cpSync(resolve(source), resolve(destination), {
271
+ recursive: true,
272
+ errorOnExist: true,
273
+ force: false,
274
+ preserveTimestamps: true,
275
+ });
276
+ },
224
277
  // Rewrites a config file we do NOT own, so it keeps the file's own mode
225
278
  // rather than imposing 0600: tightening the permissions of somebody else's
226
279
  // ~/.codex/config.toml is a side effect nobody asked this to have.
@@ -266,16 +319,21 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
266
319
  // invocation only and never to the installer's own process: a state
267
320
  // directory selected by one run must not leak into anything the operator
268
321
  // starts afterwards.
269
- run: async (cmd, args, { env: extraEnv = null } = {}) => {
322
+ run: async (cmd, args, { env: extraEnv = null, stream = false } = {}) => {
270
323
  // Always built from this layer's OWN env rather than left to spawnSync's
271
324
  // implicit inheritance, so what a child receives is a property of the
272
325
  // effects object a caller constructed and not of whatever ambient shell
273
326
  // the installer happened to start in.
274
327
  const childEnv = { ...env, ...(extraEnv ?? {}) };
275
- const r = spawnSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: childEnv });
328
+ const executable = cmd === 'npm' ? npmBin : cmd;
329
+ const r = spawnSync(executable, args, {
330
+ encoding: 'utf8',
331
+ stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
332
+ env: childEnv,
333
+ });
276
334
  if (r.status !== 0) {
277
335
  const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
278
- throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
336
+ throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
279
337
  }
280
338
  return { ok: true, code: r.status, stdout: r.stdout ?? '' };
281
339
  },
@@ -288,7 +346,11 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
288
346
  const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...env, ...(extraEnv ?? {}) } });
289
347
  return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
290
348
  },
291
- installedVersion: installedVersionOf,
349
+ installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
350
+ packageDependencies: (spec) => packageDependenciesOf(spec, npmBin),
351
+ resolvePackageVersion: (pkg, channel) => resolvePackageVersion(pkg, channel, npmBin),
352
+ codexMarketplace,
353
+ hasClaudePlugin,
292
354
  out: out ?? ((line) => process.stdout.write(`${line}\n`)),
293
355
  // Never called when assumeYes: the orchestrator takes the default itself.
294
356
  ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
@@ -296,7 +358,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
296
358
  };
297
359
  }
298
360
 
299
- export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, knownStateDirsIn };
361
+ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
300
362
 
301
363
  // -----------------------------------------------------------------------------
302
364
  // THE PAIR
@@ -305,7 +367,7 @@ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFil
305
367
  /**
306
368
  * The environment that names ONE daemon, for a single child invocation.
307
369
  *
308
- * Spec §2's rule is that a state directory and an endpoint always travel
370
+ * A state directory and its endpoint always travel
309
371
  * together; "endpoint selected, state directory defaulted" must be unreachable.
310
372
  * Every consumer downstream — ours-mcp's proxy, ours-fleet's per-role resolver,
311
373
  * ours-hermes-install — reads these three names and falls back to `~/.ours` for
package/lib/extras.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  // ours-install v3 — the four retained extras, re-pointed at the v3 arrangement.
2
2
  //
3
3
  // The v3 installer keeps harness plugins, ours-fleet, voice setup and the
4
- // copy-paste hand-off prompt; spec v3's silence about them was an oversight.
4
+ // copy-paste hand-off prompt.
5
5
  //
6
6
  // The shared daemon belongs to the operator CLI. ours-mcp is only a per-session
7
7
  // stdio adapter with no unit, and these extra phases preserve that boundary.
8
8
  //
9
9
  // Pure, like target.mjs / plan.mjs / components.mjs: no I/O, no subprocess, no
10
10
  // terminal. Every function takes what was observed and returns a plan; the
11
- // orchestrator (a later PR) is what performs it.
11
+ // orchestrator performs it.
12
12
 
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { pkgSpec } from './logic.mjs';
@@ -16,7 +16,7 @@ import { pkgSpec } from './logic.mjs';
16
16
  const cfgPath = (stateDir) => join(resolve(stateDir), 'config.json');
17
17
 
18
18
  // -----------------------------------------------------------------------------
19
- // §5 — harness plugins
19
+ // Harness plugins
20
20
  // -----------------------------------------------------------------------------
21
21
 
22
22
  export const CLAUDE_MARKET = 'adapt-toolkit/ours-claude-marketplace';
@@ -29,13 +29,12 @@ export const HARNESSES = [
29
29
  ];
30
30
 
31
31
  /**
32
- * SPEC §5 PROMISES SOMETHING TWO OF THE THREE REGISTRATIONS CANNOT DO.
32
+ * TWO OF THE THREE REGISTRATIONS CANNOT PERSIST AN ENVIRONMENT VALUE.
33
33
  *
34
- * §5: "For any other state directory the installer registers the harness MCP
35
- * entry with OURS_CONFIG=<state-dir>/config.json in its environment, so the pair
36
- * travels together." planMcpAttachment already returns exactly that harnessEnv
37
- * and the orchestrator only PRINTS it. That is not an oversight to be fixed by
38
- * wiring it up harder; none of the three registrations can carry a value:
34
+ * For a non-default state directory, planMcpAttachment exposes the required
35
+ * OURS_CONFIG value. Claude Code and Codex cannot persist that value in their
36
+ * plugin registrations, so the installer prints an explicit shell export and
37
+ * never claims it was applied automatically:
39
38
  *
40
39
  * Claude Code the marketplace plugin's mcpServers.ours is command+args, with
41
40
  * no env key, and `claude plugin install` injects nothing per
@@ -46,9 +45,7 @@ export const HARNESSES = [
46
45
  * Hermes renderConfigBlock is OUR writer, and now emits an `env:` block
47
46
  * carrying OURS_CONFIG — so for Hermes the pair is real.
48
47
  *
49
- * So §5's guarantee is ALREADY unmet today for every non-default state
50
- * directory, silently: the harness attaches to ~/.ours while the operator was
51
- * told the run targeted somewhere else. The shape is:
48
+ * The supported behavior is:
52
49
  *
53
50
  * default state directory today's behaviour, byte for byte.
54
51
  * Hermes, non-default real: the pair is handed to ours-hermes-install's
@@ -56,7 +53,7 @@ export const HARNESSES = [
56
53
  * as the ours server's own env block.
57
54
  * Claude / Codex, non-def install the plugin (it is still the right plugin)
58
55
  * and PRINT the exact line the operator must add.
59
- * Never claim §5's guarantee in the screen text.
56
+ * Never claim automatic configuration in the screen text.
60
57
  *
61
58
  * Deliberately NOT done: registering a second, user-scoped `ours` MCP server via
62
59
  * `claude mcp add --env`. Two `ours` servers in front of one harness, and which
@@ -94,7 +91,7 @@ const driveSteps = {
94
91
  codex: (channel) => [
95
92
  ['codex', 'plugin', 'marketplace', 'add', CODEX_MARKET],
96
93
  ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'],
97
- // Owner-mandated in v2 and kept: choosing the Codex plugin also installs the
94
+ // Product requirement in v2 and kept: choosing the Codex plugin also installs the
98
95
  // ours-codex live launcher, in the same step.
99
96
  ['npm', 'i', '-g', pkgSpec('codex', channel)],
100
97
  ],
@@ -122,7 +119,7 @@ const driveSteps = {
122
119
  * `env` is what an invocation must carry, and it is EMPTY unless the harness can
123
120
  * genuinely apply it. `envLine` is what the operator is told. `claimsPair` is
124
121
  * false whenever the pair is only printed — the screen text renderer reads it so
125
- * §5's guarantee cannot be claimed where it does not hold.
122
+ * the screen cannot claim that the pair was persisted where it was only printed.
126
123
  */
127
124
  export function planHarnessPlugins({
128
125
  harnesses = [],
@@ -170,7 +167,7 @@ export function planHarnessPlugins({
170
167
  }
171
168
 
172
169
  // -----------------------------------------------------------------------------
173
- // §5 — what the operator has to do BEFORE any of this works
170
+ // Preconditions the operator must satisfy before installation
174
171
  // -----------------------------------------------------------------------------
175
172
 
176
173
  /**
@@ -331,7 +328,7 @@ export function buildHandoffPromptV3({
331
328
  steps.push(
332
329
  'Finish my Telegram setup without exposing secrets: ask me for the bot name\n'
333
330
  + ' and guide me through entering the @BotFather token locally. Register the\n'
334
- + ' route, then start the connector only after I approve.',
331
+ + ' route in the connector service that ours-install already started.',
335
332
  );
336
333
  }
337
334
  if (steps.length === 0) return { text: '', empty: true };
package/lib/logic.mjs CHANGED
@@ -107,7 +107,7 @@ export function resolveChannel(raw, selfVersion = '') {
107
107
 
108
108
  // A published nightly carries the `-nightly.N` prerelease suffix the bump script writes.
109
109
  export function isNightlyVersion(version) {
110
- return /-nightly\.\d+/.test(String(version || ''));
110
+ return /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-nightly\.(?:0|[1-9]\d*)$/.test(String(version || ''));
111
111
  }
112
112
 
113
113
  // The npm dist-tag to install for one package key under a channel. Looks the key
@@ -207,15 +207,13 @@ export function planTgDaemonConfig(existing, { daemonUrl, daemonStateDir, broker
207
207
  }
208
208
 
209
209
  // ── Rooms / ours-cowork ────────────────────────────────────────────────────────
210
- // ours-cowork was a purely standalone daemon: its shipped 0.4.0 bundle has no
211
- // daemonUrl / daemonStateDir / /api/v1 anywhere and its docs said it "has no
212
- // dependency on another agent daemon". ours-cowork PR #9 (head 030b71df…) adds an
213
- // EXTERNAL daemon mode, so Rooms can now answer the same common-vs-dedicated
214
- // question the Telegram connector does. Its exact contract, as reported:
210
+ // cowork supports an external-daemon mode, so Rooms can use the same
211
+ // common-vs-dedicated selection as the Telegram connector. Its public config
212
+ // contract is:
215
213
  //
216
214
  // ~/.ours-cowork/config.json carries an OPTIONAL `daemon` block.
217
215
  // absent ⇒ EMBEDDED — cowork hosts its own daemon (what every install
218
- // before PR #9 does, and still the safe answer for one already
216
+ // before external mode existed, and still the safe answer for one already
219
217
  // running that way).
220
218
  // present ⇒ { mode: 'external', endpoint: 'http://127.0.0.1:<port>',
221
219
  // stateDir: '<absolute ours-daemon state dir>' }
@@ -236,19 +234,12 @@ export const COWORK_DEFAULT_PORT = 3052;
236
234
  export const COWORK_DAEMON_MODES = ['embedded', 'external'];
237
235
 
238
236
  // Which cowork builds understand the `daemon` block. Its config is a STRICT
239
- // document, so handing an unknown key to a build that predates PR #9 is not a
237
+ // document, so handing an unknown key to a build that predates external mode is not a
240
238
  // harmless no-op — and cowork's boot is fail-closed, so the failure surfaces as a
241
239
  // Rooms daemon that will not start rather than a warning.
242
240
  //
243
- // The FIRST published cowork that implements the external-daemon mode. Verified
244
- // against the registry rather than taken on trust:
245
- // @ours.network/cowork@nightly = 0.4.1-nightly.20260816.4aaf940
246
- // gitHead 4aaf9406016098704d06b52352f7a38adc2ef160
247
- // dist.shasum 5a6422409b1203a9bcc6aca33965fe47e9a5c17c
248
- // depends on @ours.network/sdk 1.3.1; `latest` still 0.4.0
249
- // and the packed tarball really carries it — dist/daemon.js and dist/cli.js
250
- // contain the mode enum ["embedded","external"], the endpoint+stateDir pairing
251
- // check, OURS_COWORK_DAEMON_MODE/_ENDPOINT/_STATE_DIR, and the daemon-token read.
241
+ // The first published cowork version whose package contains external-daemon
242
+ // config parsing, endpoint/state-directory pairing, and daemon-token loading.
252
243
  export const COWORK_EXTERNAL_MIN_VERSION = '0.4.1-nightly.20260816.4aaf940';
253
244
 
254
245
  // Does the cowork build actually on this machine support an external daemon?
@@ -0,0 +1,104 @@
1
+ import { join } from 'node:path';
2
+
3
+ const NUM = '(?:0|[1-9]\\d*)';
4
+ const STABLE_VERSION = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}$`);
5
+ const NIGHTLY_VERSION = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}-nightly\\.${NUM}$`);
6
+
7
+ // npm view --json normally returns a JSON string, while older/custom npm wrappers may
8
+ // return the plain version. Accept exactly one scalar either way; arrays/objects are not
9
+ // a deliberate dist-tag resolution and therefore fail closed.
10
+ export function parseNpmVersion(text) {
11
+ const raw = String(text ?? '').trim();
12
+ if (!raw) return '';
13
+ try {
14
+ const parsed = JSON.parse(raw);
15
+ return typeof parsed === 'string' ? parsed.trim() : '';
16
+ } catch {
17
+ return /^\S+$/.test(raw) ? raw : '';
18
+ }
19
+ }
20
+
21
+ export function validateChannelVersion(version, channel) {
22
+ const value = String(version ?? '').trim();
23
+ const selected = channel === 'nightly' ? 'nightly' : 'latest';
24
+ const valid = selected === 'nightly'
25
+ ? NIGHTLY_VERSION.test(value)
26
+ : STABLE_VERSION.test(value);
27
+ return valid
28
+ ? { ok: true, version: value, channel: selected }
29
+ : {
30
+ ok: false,
31
+ version: value,
32
+ channel: selected,
33
+ reason: selected === 'nightly'
34
+ ? `expected an exact X.Y.Z-nightly.N version, got ${value || '<empty>'}`
35
+ : `expected an exact stable X.Y.Z version, got ${value || '<empty>'}`,
36
+ };
37
+ }
38
+
39
+ function exactVersion(version, channel) {
40
+ const checked = validateChannelVersion(version, channel);
41
+ if (!checked.ok) throw new Error(checked.reason);
42
+ return checked.version;
43
+ }
44
+
45
+ export function buildClaudeMarketplace(version, channel) {
46
+ const pinned = exactVersion(version, channel);
47
+ return {
48
+ $schema: 'https://json.schemastore.org/claude-code-marketplace.json',
49
+ name: 'ours.network',
50
+ owner: {
51
+ name: 'Adapt Toolkit',
52
+ url: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
53
+ },
54
+ plugins: [{
55
+ name: 'ours',
56
+ displayName: 'ours',
57
+ description: 'Secure agent-to-agent communication channel over ADAPT: self-sovereign pubkey identity, end-to-end encryption.',
58
+ author: { name: 'Adapt Toolkit' },
59
+ homepage: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
60
+ repository: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
61
+ keywords: ['mcp', 'a2a', 'adapt', 'e2e', 'messaging'],
62
+ source: {
63
+ source: 'npm',
64
+ package: '@ours.network/claude-code',
65
+ version: pinned,
66
+ },
67
+ }],
68
+ };
69
+ }
70
+
71
+ export function buildCodexMarketplace(version, channel) {
72
+ const pinned = exactVersion(version, channel);
73
+ return {
74
+ name: 'ours-codex-marketplace',
75
+ interface: { displayName: 'ours.network for Codex' },
76
+ plugins: [{
77
+ name: 'ours',
78
+ source: {
79
+ source: 'npm',
80
+ package: '@ours.network/codex',
81
+ version: pinned,
82
+ registry: 'https://registry.npmjs.org',
83
+ },
84
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
85
+ category: 'Productivity',
86
+ }],
87
+ };
88
+ }
89
+
90
+ export function marketplacePaths(home) {
91
+ const root = join(home, '.ours', 'install', 'marketplaces');
92
+ const claudeRoot = join(root, 'claude-code');
93
+ const codexRoot = join(root, 'codex');
94
+ return {
95
+ claudeRoot,
96
+ claudeManifest: join(claudeRoot, '.claude-plugin', 'marketplace.json'),
97
+ codexRoot,
98
+ codexManifest: join(codexRoot, '.agents', 'plugins', 'marketplace.json'),
99
+ };
100
+ }
101
+
102
+ export function marketplaceJson(value) {
103
+ return JSON.stringify(value, null, 2) + '\n';
104
+ }
@@ -40,7 +40,7 @@ async function perform(effects, dryRun, label, thunk) {
40
40
 
41
41
  /**
42
42
  * Which components are being removed alongside this daemon. Asked once, before
43
- * anything is touched, so the §8 step 1 refusal can be resolved in the same run
43
+ * anything is touched, so a referenced-component refusal can be resolved in the same run
44
44
  * rather than sending the operator away and back.
45
45
  *
46
46
  * Non-interactively the answer is NO — assume-yes never consents to removing
@@ -81,7 +81,7 @@ export async function runUninstall(argv, effects) {
81
81
  effects.out(heading(`ours-uninstall --state-dir ${dir}`));
82
82
  if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
83
83
 
84
- // §9 the documented OURS_UNINSTALL_* contract (item 10.9), read BEFORE any
84
+ // Read the documented OURS_UNINSTALL_* contract before any
85
85
  // file is opened. A variable this uninstaller cannot deliver stops the run
86
86
  // here, naming itself and naming the replacement, rather than being silently
87
87
  // ignored while the operator's script reports success.