@ours.network/install 0.17.0-nightly.2 → 0.17.0-nightly.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.
Files changed (4) hide show
  1. package/README.md +112 -30
  2. package/install.mjs +352 -83
  3. package/lib/logic.mjs +325 -18
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -43,41 +43,98 @@ dependency on the things it installs): an ASCII banner, tasteful colour (degrade
43
43
  answers `--version` promptly. A shell alias / hanging wrapper is **never called** (that would
44
44
  hang the run) — it's reported plainly with a fix, and a manual-install path is always offered.
45
45
  If neither harness exists it says so and exits.
46
- 2. **Config-first** (first install only) — the daemon's two base settings, up front:
47
- the **broker** (end-to-end encrypted; the broker never sees message content — almost everyone
48
- just presses Enter) and the **port** (probes `3050`; only asks if it's busy; never hands out
49
- `3051`, reserved for the Telegram connector). Applied once, then the stack is built with it.
50
- 3. **Four consent gates**, each paced with a clean `✓ … No problems.` line + an explicit
46
+ 2. **Five consent gates**, each paced with a clean `✓ … No problems.` line + an explicit
51
47
  **Continue?** — never a start-twice-then-ask, never a silent failure:
52
- - **1/4 ours core (the daemon)** — write config → optional voice setup → install/start ONCE
53
- → boot service. On a re-run it reuses the running config (no re-ask) and only updates when
54
- you say yes. Complete voice setup is kept without prompting. Missing/incomplete setup is
55
- offered before the first start or pending update restart, then delegated to the canonical
56
- `ours-mcp voice-setup` provider selector and hidden API-key prompt. Accepted setup owns the
57
- one restart/readiness transaction; declining or already-ready setup preserves the normal
58
- core lifecycle. The secret is written atomically to mode-`0600` config; a failed daemon
59
- reload rolls back.
60
- - **2/4 harness plugins** — the installer **drives the plugin CLIs itself**
48
+ - **1/5 the shared ours daemon** — its own step, before every consumer, and it owns its own
49
+ configuration: the **broker** (end-to-end encrypted; the broker never sees message content —
50
+ almost everyone just presses Enter) and the **listen port**, which is now an explicit
51
+ question rather than one you only hear about when `3050` is busy. The default is still
52
+ `3050` (the next free port when that is taken), so **Enter and every non-interactive run
53
+ land exactly where they always did**. The answer is validated (a real port, not reserved by
54
+ another component, not already in use, not already claimed by another daemon in this run),
55
+ persisted to `~/.ours/config.json`, and it is the endpoint everything below is wired to.
56
+ Then: write config → optional voice setup → install/start ONCE → boot service. On a re-run
57
+ it reuses the running config (no re-ask) and only updates when you say yes. Complete voice
58
+ setup is kept without prompting. Missing/incomplete setup is offered before the first start
59
+ or pending update restart, then delegated to the canonical `ours-mcp voice-setup` provider
60
+ selector and hidden API-key prompt. Accepted setup owns the one restart/readiness
61
+ transaction; declining or already-ready setup preserves the normal core lifecycle. The
62
+ secret is written atomically to mode-`0600` config; a failed daemon reload rolls back.
63
+ - **2/5 harness plugins** — the installer **drives the plugin CLIs itself**
61
64
  (`claude plugin marketplace add …` + `claude plugin install ours@ours.network`;
62
65
  `codex plugin marketplace add …` + `codex plugin add ours@ours-codex-marketplace`). Choosing
63
66
  Codex also installs the `ours-codex` live launcher in the same step. Any failure / alias
64
67
  prints the exact manual commands and continues — it **never dead-ends**.
65
- - **3/4 ours-fleet** — makes your harnesses persistent, always-online agent teams that survive
68
+ - **3/5 ours-fleet** — makes your harnesses persistent, always-online agent teams that survive
66
69
  a reboot; runs `ours-fleet init`. Default **Yes**.
67
- - **4/4 Telegram connector** — install-only (no bot tokens here), **pointed at the daemon
68
- step 1 just built**, then optionally as a boot service.
69
- 4. **Summary + hand-off** — a recap (skipped/failed rows call out the fix), then a **literal
70
- copy-paste prompt** (root identity + fleet + Telegram) with the steps for any skipped/failed
71
- component dropped out. Copied to the clipboard where supported.
70
+ - **4/5 Telegram connector** — install-only (no bot tokens here), then a question asked
71
+ **independently of every other consumer**: use the shared daemon from step 1, or run against
72
+ its **own dedicated daemon**? Default (and Enter, and non-interactive) is the shared one.
73
+ A dedicated daemon is provisioned with its own port, its own state directory
74
+ (`~/.ours-tg`) and its own boot unit (`ours-tg.service`), and the connector is wired to
75
+ that endpoint. Then optionally installed as a boot service itself.
76
+ - **5/5 Rooms (ours-cowork)** — durable mission rooms. Default **No**. Configures its own
77
+ surface (the deployment's broker, its state directory `~/.ours-cowork`, and its loopback
78
+ **console port** — default `3052`, validated the same way), then asks the same
79
+ shared-vs-dedicated daemon question, defaulting to shared. A dedicated Rooms daemon gets
80
+ `~/.ours-rooms` and `ours-rooms.service`. Then `ours-cowork install-service`. See
81
+ **Daemon topology** for when the question is asked at all.
82
+ 3. **Summary + hand-off** — a recap (skipped/failed rows call out the fix), then a **literal
83
+ copy-paste prompt** (human identity + fleet + Telegram + Rooms) with the steps for any
84
+ skipped/failed component dropped out. Copied to the clipboard where supported.
72
85
 
73
86
  The human identity is created idempotently after the daemon becomes reachable. Because
74
87
  `curl … | bash` gives the script its input over the pipe, every prompt is read from the
75
88
  controlling terminal (`/dev/tty`), so the flow still works piped.
76
89
 
77
- ## One daemon, and everything pointed at it
90
+ ## Daemon topology
78
91
 
79
- A clean deployment installs, configures and starts **one** ours daemon first, then wires every
80
- client to that same daemon. What that takes differs per client:
92
+ A clean deployment installs, configures and starts **one shared** ours daemon first, then wires
93
+ every client to that same daemon. That is the default and the backward-compatible answer.
94
+
95
+ **Optional isolation.** The Telegram connector and Rooms may each instead be given their own
96
+ daemon, chosen independently of one another. Isolation is only real when all three of these are
97
+ separate, which is what the installer provisions:
98
+
99
+ | | shared | dedicated (Telegram) | dedicated (Rooms) |
100
+ |---|---|---|---|
101
+ | listen port | your step-1 answer | its own, validated against every other daemon in the run | likewise |
102
+ | state directory | `~/.ours` | `~/.ours-tg` — the daemon's API token lives here | `~/.ours-rooms` |
103
+ | boot unit | `ours.service` | `ours-tg.service` (via `OURS_SERVICE_NAME`) | `ours-rooms.service` |
104
+ | config file | `~/.ours/config.json` | `~/.ours-tg/config.json` | `~/.ours-rooms/config.json` |
105
+
106
+ Without a distinct service name, `ours-mcp install-service` would write the **same** unit for
107
+ both and the second daemon would silently overwrite the first's port and state directory. See
108
+ `packages/core/src/service-instance.ts`; a daemon with no instance name keeps exactly the
109
+ historical `ours.service` / `solutions.adaptframework.ours`.
110
+
111
+ **Rooms has a third answer.** `ours-cowork` used to host its own daemon, always. It now also
112
+ supports an **external** one, so Rooms answers the same shared-vs-dedicated question — plus
113
+ `embedded`, cowork's own, which is what every install predating that support runs.
114
+
115
+ Its config carries an optional `daemon` block. Absent means embedded; external is
116
+ `{ mode: 'external', endpoint, stateDir }` and **requires both halves**, because cowork stores no
117
+ token and its SDK reads `<stateDir>/daemon-token`. Env equivalents are `OURS_COWORK_DAEMON_MODE`
118
+ / `_ENDPOINT` / `_STATE_DIR`, and the service unit carries only those — never a token.
119
+
120
+ Two things follow from cowork's boot being **fail-closed** (an unreachable endpoint, a non-ours
121
+ daemon, or a mismatched state directory aborts startup, with no embedded fallback):
122
+
123
+ - A build that **predates** the external mode is never handed a block. The check is on the
124
+ **installed version**, not the channel: `COWORK_EXTERNAL_MIN_VERSION` in `lib/logic.mjs` is
125
+ pinned to `0.4.1-nightly.20260816.4aaf940`, the first published cowork that implements it
126
+ (verified against the registry *and* the tarball's contents). A channel-only gate would have
127
+ accepted an earlier nightly of the same core version. `cowork@latest` (0.4.0) predates the
128
+ mode, so the stable installer keeps Rooms embedded, names the build it found and the version it
129
+ needed, and points at `OURS_CHANNEL=nightly`. An unreadable version keeps Rooms embedded too —
130
+ it never guesses.
131
+ - An install **already running embedded** is never migrated behind the user's back — headless
132
+ runs leave it exactly as it is, and interactive runs ask first.
133
+
134
+ Note the two different `stateDir` keys: the top-level one is cowork's own private state;
135
+ `daemon.stateDir` is the **ours daemon's** state directory. Confusing them fails closed at boot.
136
+
137
+ What wiring a client to a daemon takes differs per client:
81
138
 
82
139
  - **The harness plugins** need nothing extra: each is `ours-mcp proxy`, which reads the daemon's
83
140
  own config (`OURS_CONFIG`, else `~/.ours/config.json`). `autoStart` is off by default, so a proxy
@@ -104,9 +161,27 @@ while the summary claims success.
104
161
 
105
162
  ## Release channel
106
163
 
107
- `OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) installs the `nightly` dist-tag for the packages
108
- that publish one — `mcp`, `tg-connector`, and the `claude-code` / `codex` / `hermes` launchers —
109
- while `@ours.network/fleet` stays `@latest` always (it publishes no nightly).
164
+ `OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) installs each package's **own** prerelease
165
+ dist-tag. The tag is not the same string everywhere, so the mapping is per package:
166
+
167
+ | package | stable channel | nightly channel |
168
+ |---|---|---|
169
+ | `mcp`, `tg-connector`, `claude-code`, `codex`, `hermes` | `latest` | `nightly` |
170
+ | `fleet` | `latest` | `nightly` |
171
+ | `cowork` (Rooms) | `latest` | `nightly` |
172
+
173
+ `fleet` follows the channel: it publishes its own `nightly` dist-tag, and the nightly stack needs
174
+ the fleet build carrying the SDK integration. A nightly installer that quietly installed stable
175
+ fleet is the same split-brain deployment the channel exists to prevent.
176
+
177
+ `cowork` publishes `nightly` alongside every other service (it previously used `next`). The
178
+ nightly channel must reach that line, because the external-daemon mode the Rooms step configures
179
+ ships there; taking `latest` would pair a config carrying a `daemon` block with a build that
180
+ predates it.
181
+
182
+ A package with no mapping for the selected channel installs `@latest` rather than a guessed tag,
183
+ because a 404 fails the *whole* install — which is also why the nightly installer must not be
184
+ published before every package it names actually has the tag it will ask for.
110
185
 
111
186
  **With no explicit selection the installer follows its own version.** A published nightly build
112
187
  carries the `-nightly.N` suffix the release bump stamps, so `npm i -g @ours.network/install@nightly`
@@ -140,8 +215,14 @@ is reported and left unchanged.
140
215
  | `OURS_CONFIG` | daemon config file location (default `~/.ours/config.json`) |
141
216
  | `OURS_STATE_DIR` | daemon state directory (default `~/.ours`) — also what the Telegram connector is told to expect |
142
217
  | `OURS_TG_CONFIG` | Telegram connector config file location (default `~/.ours-telegram/config.json`) |
218
+ | `OURS_COWORK_CONFIG` | Rooms config file location (default `~/.ours-cowork/config.json`) |
219
+ | `OURS_COWORK_DAEMON_MODE` / `_ENDPOINT` / `_STATE_DIR` | cowork's own env equivalents of its `daemon` block (read by cowork, not set by the installer) |
143
220
  | `OURS_CHANNEL` | `nightly` or `latest`; unset follows the installer's own version |
144
221
 
222
+ A non-interactive run takes the shared daemon on its existing default port and installs no
223
+ dedicated daemon and no Rooms — the topology is unchanged from before this flow existed. It also
224
+ never converts an existing embedded Rooms install to an external daemon.
225
+
145
226
  ## Uninstall
146
227
 
147
228
  The companion `uninstall.sh` reverses what the installers created — same thin-bootstrap +
@@ -183,10 +264,11 @@ OURS_UNINSTALL_DAEMON=yes \
183
264
  - This package is **not published to npm** (`private: true`); it ships as the hosted
184
265
  `install.sh` bootstrap plus the `install.mjs` Node installer (and its `lib/`), and exposes the
185
266
  `ours-install` bin. The pieces it installs — the daemon (`@ours.network/mcp`), the harness
186
- plugins via each marketplace, `@ours.network/fleet`, and `@ours.network/tg-connector` — are the
187
- published components.
267
+ plugins via each marketplace, `@ours.network/fleet`, `@ours.network/tg-connector`, and
268
+ `@ours.network/cowork` — are the published components.
188
269
  - **Idempotent + safe to re-run.** A re-run adds a skipped piece, re-points the plugins, or (only
189
270
  when you say yes) updates a component; an already-current daemon is left untouched, its running
190
271
  port and complete voice setup are reused everywhere — and the Telegram connector's daemon
191
- selection is rewritten only if it actually changed. Bot tokens and fleet roles remain in the
192
- copy-paste hand-off; provider keys never enter that prompt or agent chat.
272
+ selection, a dedicated daemon's config, and the Rooms config are each rewritten only if they
273
+ actually changed. Bot tokens and fleet roles remain in the copy-paste hand-off; provider keys
274
+ never enter that prompt or agent chat.
package/install.mjs CHANGED
@@ -2,9 +2,16 @@
2
2
  // ours.network — the unified `ours-install` experience (the real UX behind install.sh's thin
3
3
  // bootstrap, and the `ours-install` command once the stack is on the machine).
4
4
  //
5
- // ONE installer for the WHOLE stack — ours core (the daemon) + the harness plugins (Claude Code /
6
- // Codex / Hermes) + ours-fleet + the Telegram connector — for someone who ALREADY has Claude,
7
- // Codex, and/or Hermes.
5
+ // ONE installer for the WHOLE stack — the shared ours daemon + the harness plugins (Claude Code /
6
+ // Codex / Hermes) + ours-fleet + the Telegram connector + Rooms (ours-cowork) — for someone who
7
+ // ALREADY has Claude, Codex, and/or Hermes.
8
+ //
9
+ // TOPOLOGY. Step 1 installs and configures ONE shared daemon and the user picks its listen port
10
+ // there; every consumer below is wired to that endpoint. The Telegram connector may instead be
11
+ // given its OWN daemon — its own port, state directory and boot unit — chosen independently, and
12
+ // defaulting to the shared one so Enter and non-interactive runs keep the historical topology.
13
+ // Rooms answers the same question, plus a third answer the connector has no use for: keeping
14
+ // cowork's own EMBEDDED daemon, which is what every pre-PR#9 cowork install runs (see step 5).
8
15
  // Its whole job: install the stack cleanly, then hand back ONE copy-paste prompt the user drops
9
16
  // into their agent to finish remaining configuration conversationally. Voice API credentials are
10
17
  // the one guided secret flow: interactive, masked, optional, and written atomically with mode 0600.
@@ -28,15 +35,19 @@ import {
28
35
  detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
29
36
  voiceSetupStatus, resolveSharedBroker, tgConfigPath, planTgDaemonConfig, daemonEndpoint,
30
37
  DEFAULT_PORT, resolveChannel, pkgSpec,
38
+ validateDaemonPort, planPorts, dedicatedDaemonPaths, DEDICATED_INSTANCES,
39
+ coworkConfigPath, planCoworkConfig, COWORK_DEFAULT_PORT, coworkDaemonMode,
40
+ coworkSupportsExternalDaemon, COWORK_EXTERNAL_MIN_VERSION,
31
41
  } from './lib/logic.mjs';
32
42
  import { atomicWriteConfig } from './lib/config.mjs';
33
43
 
34
44
  const NPM = process.env.OURS_NPM || 'npm';
35
- // Release channel: OURS_CHANNEL=nightly installs @nightly for mcp/tg-connector/plugin
36
- // launchers but keeps @ours.network/fleet at @latest (fleet has no nightly). With no
37
- // explicit selection the installer follows its OWN channel, so a nightly installer
38
- // builds a nightly stack instead of silently mixing tags across the connector's
39
- // architecture boundary (see resolveChannel).
45
+ // Release channel: OURS_CHANNEL=nightly installs each package's PRERELEASE dist-tag —
46
+ // @nightly for mcp/tg-connector/fleet/the plugin launchers, and @latest for cowork,
47
+ // and @next for cowork, whose repo has always called its prerelease line `next`
48
+ // (see PKG_CHANNEL_TAGS in lib/logic.mjs). With no explicit
49
+ // selection the installer follows its OWN channel, so a nightly installer builds a
50
+ // nightly stack instead of silently mixing tags across an architecture boundary.
40
51
  const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL, pkgVersion());
41
52
  const spec = (pkgKey) => pkgSpec(pkgKey, CHANNEL); // → "@ours.network/<key>@<tag>"
42
53
  let DRY = !!process.env.OURS_INSTALL_DRY_RUN;
@@ -49,10 +60,11 @@ const line = (s = '') => sink(`${s}\n`);
49
60
  const say = (s) => sink(`ours: ${s}\n`);
50
61
 
51
62
  // --- external command helpers (never throw; the installer degrades, it doesn't crash) ----------
52
- function run(bin, args, { capture = false, timeout } = {}) {
63
+ function run(bin, args, { capture = false, timeout, env } = {}) {
53
64
  const r = spawnSync(bin, args, {
54
65
  encoding: 'utf8',
55
66
  timeout,
67
+ env: env ? { ...process.env, ...env } : process.env,
56
68
  stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
57
69
  });
58
70
  const timedOut = !!(r.error && (r.error.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'));
@@ -97,9 +109,13 @@ function daemonLifecycleState() {
97
109
  return /^\s*pid:\s*\d+/m.test(status.out) ? 'managed' : 'external';
98
110
  }
99
111
  const daemonRunning = () => daemonLifecycleState() !== 'stopped';
112
+ // The installed version of a global package, INCLUDING any prerelease suffix. The
113
+ // suffix is not cosmetic here: the Rooms daemon guard compares against an exact
114
+ // `0.4.1-nightly.<date>.<sha>` floor, and truncating at the dash would make every
115
+ // 0.4.1 nightly look alike — including ones published before the mode existed.
100
116
  const globalVersion = (pkg) => {
101
117
  const ls = run(NPM, ['ls', '-g', pkg], { capture: true }).out;
102
- const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@([0-9][0-9.]*)'));
118
+ const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)'));
103
119
  return m ? m[1] : '';
104
120
  };
105
121
 
@@ -194,10 +210,13 @@ const USAGE = `ours-install — the unified ours.network stack installer.
194
210
 
195
211
  ours-install [--dry-run] [--help] [--version]
196
212
 
197
- Guided ~3-minute setup for the whole stack: ours core (the daemon), the harness
198
- plugins (Claude Code + Codex + Hermes), ours-fleet, and the Telegram connector — then one
199
- copy-paste hand-off prompt. You approve each step; re-run any time to add a piece
200
- or update.
213
+ Guided ~3-minute setup for the whole stack: the shared ours daemon (you pick its
214
+ port), the harness plugins (Claude Code + Codex + Hermes), ours-fleet, the Telegram
215
+ connector, and Rooms (ours-cowork) — then one copy-paste hand-off prompt. You approve
216
+ each step; re-run any time to add a piece or update.
217
+
218
+ Telegram can share the daemon from step 1 or be given its own (its own port, state
219
+ directory and boot service); Enter keeps the shared one.
201
220
 
202
221
  --dry-run walk the whole flow and print what it WOULD do — install/change nothing
203
222
  --help show this help and exit
@@ -296,56 +315,44 @@ async function main() {
296
315
  line('');
297
316
  cont();
298
317
 
299
- // ============================================================================================
300
- // STEP 0 — the two config questions (asked ONCE, up front). SKIPPED entirely when a daemon is
301
- // already configured (update path reuses its port/broker; delta #1859).
302
- // ============================================================================================
303
318
  const status0 = parseStatus(daemonStatusText());
304
319
  let chosenBroker; // undefined = keep default / existing
305
320
  let chosenPort = status0.port || DEFAULT_PORT;
306
321
  const configFirst = !daemonInstalled;
307
322
 
308
- if (configFirst) {
309
- line(heading('A couple of quick settings'));
310
- // 0a — broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
311
- line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
312
- line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
313
- line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
314
- const custom = yes(' Use a custom broker address?', false);
315
- if (custom) {
316
- line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
317
- const entered = ask(' Enter the broker address: ', '');
318
- const v = validateBroker(entered);
319
- if (entered && v.ok && !v.empty) {
320
- // Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
321
- const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
322
- if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
323
- else line(ok('using the standard broker.'));
324
- } else {
325
- if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
326
- else line(ok('using the standard broker.'));
327
- }
328
- } else {
329
- line(ok('using the standard broker.'));
330
- }
323
+ // Every port this run has committed to, so a later daemon can't be handed one an
324
+ // earlier daemon claimed. A live bind probe cannot see these — nothing is
325
+ // listening on them yet — which is exactly why they're tracked by hand.
326
+ const claimedPorts = [];
327
+ // The finished topology, for the end-of-run cross-check. Each entry is one thing that
328
+ // will try to BIND a port, named so a collision can be reported in the user's terms.
329
+ const topology = [];
330
+ const claimPort = (port, label) => {
331
+ if (!Number.isInteger(port)) return;
332
+ if (!claimedPorts.includes(port)) claimedPorts.push(port);
333
+ if (label) topology.push({ label, port });
334
+ };
331
335
 
332
- // 0b — port: probe 3050; only ask if busy. Minimize the concept.
333
- if (!portTakenSync(DEFAULT_PORT)) {
334
- chosenPort = DEFAULT_PORT;
335
- line(ok(`Using local port ${DEFAULT_PORT}.`));
336
- } else {
337
- line(info(`The standard local port (${DEFAULT_PORT}) is already in use on your machine.`));
338
- let candidate = suggestPort(DEFAULT_PORT + 1, portTakenSync);
339
- const raw = ask(` Pick another number for the ours daemon? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
340
- const parsed = parsePort(raw, candidate);
341
- candidate = suggestPort(parsed.ok ? parsed.port : candidate, portTakenSync);
342
- chosenPort = candidate;
343
- line(ok(`Using local port ${chosenPort}.`));
336
+ // Ask for ONE daemon's port, validate it, and keep asking until the answer is
337
+ // usable. Enter (and every non-interactive run) takes `def` unchanged — that is
338
+ // what keeps the historical behaviour and scripted installs identical.
339
+ const askDaemonPort = (prompt, def) => {
340
+ let candidate = def;
341
+ for (let attempt = 0; attempt < 3; attempt++) {
342
+ const raw = ask(` ${prompt} ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
343
+ const v = validateDaemonPort(raw, { fallback: candidate, isTaken: portTakenSync, taken: claimedPorts });
344
+ if (v.ok) return v.port;
345
+ line(warn(`${v.reason}.`));
346
+ if (!interactive) break; // no one to re-ask; fall through to a suggestion
347
+ candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
348
+ line(info(`Suggesting ${candidate} instead.`));
344
349
  }
345
- line('');
346
- line(ok(`Config ready — broker: ${chosenBroker ? 'custom' : 'standard'}, port: ${chosenPort}.`));
347
- cont();
348
- }
350
+ // Out of attempts (or headless): take the first genuinely free port rather
351
+ // than persisting one we know is unusable.
352
+ const fallback = suggestPort(candidate, (p) => claimedPorts.includes(p) || portTakenSync(p));
353
+ line(ok(`Using port ${fallback}.`));
354
+ return fallback;
355
+ };
349
356
 
350
357
  // Track outcomes for the summary + hand-off.
351
358
  const summary = [];
@@ -432,13 +439,57 @@ async function main() {
432
439
  };
433
440
 
434
441
  // ============================================================================================
435
- // STEP 1 / 4 — ours core (the daemon). Config-first: write config → optional voice → start ONCE.
442
+ // STEP 1 / 5 — the SHARED ours daemon. Its own visible step, and it owns its own configuration
443
+ // (broker + listen port) rather than a nameless "quick settings" preamble: every consumer below
444
+ // is wired to the endpoint chosen HERE, so the choice belongs to the step that makes it.
445
+ // Config-first within the step: choose → write config → optional voice → start ONCE.
436
446
  // ============================================================================================
437
- line(heading('1/4 — ours core (the daemon)'));
438
- line(info('This is the piece that lets your agents talk to each other securely. Everything else'));
439
- line(info('needs it.'));
447
+ line(heading('1/5 — the shared ours daemon'));
448
+ line(info('This is the piece that lets your agents talk to each other securely. The harness'));
449
+ line(info('plugins, ours-fleet and the Telegram connector all connect to it — Telegram can be'));
450
+ line(info('given its own instead, later — and so can Rooms.'));
440
451
  const before = parseVersion(versionBefore);
441
452
 
453
+ if (configFirst) {
454
+ // Broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
455
+ line('');
456
+ line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
457
+ line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
458
+ line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
459
+ const custom = yes(' Use a custom broker address?', false);
460
+ if (custom) {
461
+ line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
462
+ const entered = ask(' Enter the broker address: ', '');
463
+ const v = validateBroker(entered);
464
+ if (entered && v.ok && !v.empty) {
465
+ // Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
466
+ const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
467
+ if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
468
+ else line(ok('using the standard broker.'));
469
+ } else {
470
+ if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
471
+ else line(ok('using the standard broker.'));
472
+ }
473
+ } else {
474
+ line(ok('using the standard broker.'));
475
+ }
476
+
477
+ // Listen port. ALWAYS asked now, so the shared daemon's endpoint is a decision the
478
+ // user makes rather than one they only hear about when 3050 happens to be busy.
479
+ // The default is still 3050 (the next free port when it is taken), so Enter and
480
+ // every non-interactive run land exactly where they always did.
481
+ line('');
482
+ line(info('The daemon listens on a local port. Everything else in this install is pointed at it.'));
483
+ const portDefault = portTakenSync(DEFAULT_PORT) ? suggestPort(DEFAULT_PORT + 1, portTakenSync) : DEFAULT_PORT;
484
+ if (portDefault !== DEFAULT_PORT) line(info(`The standard port (${DEFAULT_PORT}) is already in use on your machine.`));
485
+ chosenPort = askDaemonPort('Which local port should the shared daemon use?', portDefault);
486
+ line(ok(`Shared daemon: port ${chosenPort}, broker ${chosenBroker ? 'custom' : 'standard'}.`));
487
+ line('');
488
+ } else {
489
+ line(ok(`Shared daemon already configured — port ${chosenPort}. Keeping it.`));
490
+ }
491
+ claimPort(chosenPort, 'the shared ours daemon');
492
+
442
493
  if (!daemonInstalled) {
443
494
  const goCore = yes(' Install and start it?', true);
444
495
  if (!goCore) {
@@ -628,10 +679,10 @@ async function main() {
628
679
  }
629
680
 
630
681
  // ============================================================================================
631
- // STEP 2 / 4 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
682
+ // STEP 2 / 5 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
632
683
  // CLIs for Claude/Codex; Hermes installs via npm + ours-hermes-install (no CLI driving).
633
684
  // ============================================================================================
634
- line(heading('2/4 — harness plugins'));
685
+ line(heading('2/5 — harness plugins'));
635
686
  line(info('These teach Claude Code, Codex, and Hermes the ours skills, so you can just talk to your'));
636
687
  line(info("agent to message people and set things up. I'll install them for you — no commands to type."));
637
688
  for (const h of harnesses) {
@@ -644,9 +695,9 @@ async function main() {
644
695
  }
645
696
 
646
697
  // ============================================================================================
647
- // STEP 3 / 4 — ours-fleet. Appealing wording (owner edit #4); default YES.
698
+ // STEP 3 / 5 — ours-fleet. Appealing wording (owner edit #4); default YES.
648
699
  // ============================================================================================
649
- line(heading('3/4 — ours-fleet (your always-online agent team)'));
700
+ line(heading('3/5 — ours-fleet (your always-online agent team)'));
650
701
  line(info('This makes your harnesses PERSISTENT: Claude Code and Codex stop being just a terminal'));
651
702
  line(info('session and become always-online daemons that survive a reboot. Stand up your own team'));
652
703
  line(info('of always-online developers, combine harnesses, run several Claude Codes, and link them'));
@@ -674,22 +725,82 @@ async function main() {
674
725
  }
675
726
  cont(goFleet);
676
727
 
677
- // Give the Telegram connector the ONE daemon this install just built: its loopback
728
+ // The broker the WHOLE deployment shares, whichever daemon a consumer talks to.
729
+ const sharedBroker = () => resolveSharedBroker({
730
+ chosenBroker,
731
+ statusBroker: status0.broker,
732
+ configBroker: readConfigObject().brokerUrl,
733
+ });
734
+
735
+ // Provision a daemon that belongs to ONE consumer: its own config file, its own
736
+ // state directory, its own port, and — via core's OURS_SERVICE_NAME — its own boot
737
+ // unit, so `install-service` cannot overwrite the shared daemon's. Returns the
738
+ // endpoint + state dir to wire that consumer to, and whether it came up.
739
+ async function provisionDedicatedDaemon({ instance, port, label }) {
740
+ const { stateDir, configPath: cfgPath, serviceName } = dedicatedDaemonPaths(homedir(), instance);
741
+ const env = { OURS_CONFIG: cfgPath, OURS_STATE_DIR: stateDir, OURS_SERVICE_NAME: serviceName };
742
+ // The dedicated daemon has to exist as a package before it can be started; on a
743
+ // fresh machine step 1 already installed it, but a re-run that skipped core has not.
744
+ await actSpin(`ensuring ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
745
+ const desired = { port, stateDir, serviceName };
746
+ const broker = sharedBroker();
747
+ if (broker) desired.brokerUrl = broker;
748
+ let existing = {};
749
+ try { existing = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
750
+ const sameAlready = existing.port === port && existing.stateDir === stateDir && existing.serviceName === serviceName;
751
+ if (sameAlready) {
752
+ line(ok(`The ${label} daemon is already configured on port ${port} — no change.`));
753
+ // Nothing to change AND it is already up: do not touch it. `install-service` STOPS
754
+ // the daemon before rewriting the unit, so re-running it here would bounce a healthy
755
+ // daemon for no reason.
756
+ if (!DRY && run('ours-mcp', ['status'], { capture: true, env }).ok) {
757
+ line(ok(`Dedicated ${label} daemon already running on port ${port} — left alone.`));
758
+ return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: true, port };
759
+ }
760
+ } else {
761
+ await act(`write ${cfgPath} (dedicated ${label} daemon, port ${port}, state ${stateDir})`, async () => {
762
+ atomicWriteConfig(cfgPath, mergeConfig(existing, desired));
763
+ return { ok: true };
764
+ });
765
+ }
766
+ const started = await act(`ours-mcp start (dedicated ${label} daemon, port ${port})`, async () => run('ours-mcp', ['start'], { env }));
767
+ const svc = await act(`ours-mcp install-service (dedicated ${label} daemon, unit ours-${serviceName})`, async () => run('ours-mcp', ['install-service'], { env }));
768
+ // Same recovery as the shared daemon: install-service STOPS the daemon before it
769
+ // writes the unit, so a failure there leaves nothing listening on this port.
770
+ let running = started.ok;
771
+ if (!DRY && !svc.ok) {
772
+ running = run('ours-mcp', ['status'], { capture: true, env }).ok;
773
+ if (!running) {
774
+ line(info(`the boot-service step stopped the ${label} daemon before it failed — restarting it.`));
775
+ running = run('ours-mcp', ['start'], { env }).ok;
776
+ }
777
+ line(warn(`the ${label} daemon has no boot service — retry '${c.cyan(`OURS_CONFIG=${cfgPath} OURS_SERVICE_NAME=${serviceName} ours-mcp install-service`)}'.`));
778
+ }
779
+ if (running || DRY) line(ok(`Dedicated ${label} daemon ready on port ${port} (state ${stateDir}, unit ours-${serviceName}).`));
780
+ else line(warn(`could not start the dedicated ${label} daemon — run '${c.cyan(`OURS_CONFIG=${cfgPath} ours-mcp start`)}'.`));
781
+ return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: running || DRY, port };
782
+ }
783
+
784
+ // Ask one consumer whether it uses the COMMON daemon or gets its own. Enter and
785
+ // every non-interactive run answer "common" — the historical topology.
786
+ const askDaemonMode = (what) => {
787
+ line(info(`${what} can share the daemon from step 1, or run against its own isolated one.`));
788
+ line(info('Sharing is right for almost everyone — press Enter. A dedicated daemon gets its own'));
789
+ line(info('port, state directory and boot service, and does not see the shared daemon\'s identities.'));
790
+ return yes(` Give ${what} its OWN dedicated daemon?`, false) ? 'dedicated' : 'common';
791
+ };
792
+
793
+ // Give the Telegram connector the daemon it was assigned: that daemon's loopback
678
794
  // endpoint, the state directory that endpoint's API token belongs to, and — for a
679
795
  // pre-0.3.3 connector that still meets the daemon at a broker instead — that broker.
680
796
  // Idempotent: an unchanged selection writes nothing. Returns { changed, hadPrevious }
681
797
  // so the caller can warn about a service unit that froze an older selection.
682
- async function writeTgDaemonConfig({ chosenPort, chosenBroker, status0 }) {
798
+ async function writeTgDaemonConfig({ endpoint, stateDir }) {
683
799
  const path = tgConfigPath(process.env, homedir());
684
- const stateDir = daemonStateDir();
685
800
  const desired = {
686
- daemonUrl: daemonEndpoint(chosenPort),
801
+ daemonUrl: endpoint,
687
802
  daemonStateDir: stateDir,
688
- brokerUrl: resolveSharedBroker({
689
- chosenBroker,
690
- statusBroker: status0.broker,
691
- configBroker: readConfigObject().brokerUrl,
692
- }),
803
+ brokerUrl: sharedBroker(),
693
804
  };
694
805
  let existing = {};
695
806
  try { existing = JSON.parse(readFileSync(path, 'utf8')); } catch { /* absent or unreadable */ }
@@ -708,36 +819,52 @@ async function main() {
708
819
  }
709
820
 
710
821
  // ============================================================================================
711
- // STEP 4 / 4 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
822
+ // STEP 4 / 5 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
712
823
  // ============================================================================================
713
- line(heading('4/4 — Telegram connector'));
824
+ line(heading('4/5 — Telegram connector'));
714
825
  line(info('This bridges a Telegram bot to your Ours node, so you can talk to your agent from'));
715
826
  line(info("Telegram. (You'll set up the actual bot later, with your agent — not here.)"));
716
827
  const goTg = yes(' Install it?', false);
717
828
  if (goTg) {
718
829
  await actSpin(`installing ${spec('tg-connector')}…`, `npm i -g ${spec('tg-connector')}`, () => runAsync(NPM, ['i', '-g', spec('tg-connector')]));
719
- // POINT IT AT THE ONE DAEMON — BEFORE it is started or installed as a service.
830
+ // WHICH daemon — asked independently of every other consumer, and answered
831
+ // "common" by Enter / non-interactive so the historical topology is the default.
832
+ line('');
833
+ const tgMode = askDaemonMode('the Telegram connector');
834
+ let tgDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir(), port: chosenPort, mode: 'common' };
835
+ if (tgMode === 'dedicated') {
836
+ const instance = DEDICATED_INSTANCES.telegram;
837
+ const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
838
+ const port = askDaemonPort('Which local port should the Telegram daemon use?', suggested);
839
+ claimPort(port, 'the dedicated Telegram daemon');
840
+ const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Telegram' });
841
+ tgDaemon = { ...provisioned, mode: 'dedicated' };
842
+ } else {
843
+ line(ok(`Telegram will use the shared daemon on port ${chosenPort}.`));
844
+ }
845
+ // POINT IT AT THAT DAEMON — BEFORE it is started or installed as a service.
720
846
  // The connector never inherits ~/.ours/config.json (its SDK reports configPath:
721
847
  // null unless told otherwise), and `install-service` bakes whatever it resolves
722
848
  // into the unit as environment variables that outrank the file from then on. So
723
849
  // the daemon's identity has to be in its config BEFORE either happens. See
724
850
  // planTgDaemonConfig for why all three keys are written.
725
- const tgConfigured = await writeTgDaemonConfig({ chosenPort, chosenBroker, status0 });
851
+ const tgConfigured = await writeTgDaemonConfig(tgDaemon);
852
+ const where = tgDaemon.mode === 'dedicated' ? `its own daemon on port ${tgDaemon.port}` : `the shared daemon on port ${tgDaemon.port}`;
726
853
  const asService = yes(' Keep it running in the background so it starts automatically on boot?', true);
727
854
  if (asService) {
728
855
  const svc = await act('ours-tg-connector install-service (starts on boot)', async () => run('ours-tg-connector', ['install-service']));
729
- if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at the daemon on port ${chosenPort}. No problems.`));
856
+ if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at ${where}. No problems.`));
730
857
  else line(warn(`connector installed, but the service didn't start — retry '${c.cyan('ours-tg-connector install-service')}'.`));
731
- record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · daemon ${chosenPort}` });
858
+ record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
732
859
  } else {
733
- line(ok(`Telegram connector installed, pointed at the daemon on port ${chosenPort}. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
860
+ line(ok(`Telegram connector installed, pointed at ${where}. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
734
861
  // A connector already installed as a service froze its OLD daemon selection into
735
862
  // the unit's environment, which outranks the file we just wrote. Config alone
736
863
  // cannot repair that — say so plainly rather than let it look fixed.
737
864
  if (tgConfigured.changed && tgConfigured.hadPrevious) {
738
865
  line(warn(`if you previously ran '${c.cyan('ours-tg-connector install-service')}', re-run it — the old service froze the previous daemon selection in its unit.`));
739
866
  }
740
- record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · daemon ${chosenPort}` });
867
+ record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
741
868
  }
742
869
  } else {
743
870
  line(info('skipped cleanly.'));
@@ -745,6 +872,146 @@ async function main() {
745
872
  }
746
873
  cont(goTg);
747
874
 
875
+ // ============================================================================================
876
+ // STEP 5 / 5 — Rooms (ours-cowork). Two independent things get configured here.
877
+ //
878
+ // ITS OWN SURFACE — the deployment broker, its private state directory, and its loopback
879
+ // console/REST port. Those it has always had.
880
+ //
881
+ // WHICH DAEMON — ours-cowork used to host its own, always. It now supports an EXTERNAL ours
882
+ // daemon (cowork PR #9), so Rooms answers the same common-vs-dedicated question the Telegram
883
+ // connector does, plus a third state the connector does not have: EMBEDDED, cowork's own.
884
+ // Contract (see logic.mjs): the `daemon` block is optional; absent means embedded; external is
885
+ // { mode:'external', endpoint, stateDir } and REQUIRES both halves, because cowork holds no
886
+ // token and its SDK reads <stateDir>/daemon-token.
887
+ //
888
+ // Boot is FAIL-CLOSED on an unreachable endpoint, a non-ours daemon, or a mismatched state
889
+ // directory — there is no embedded fallback. So an install that is ALREADY running embedded is
890
+ // never migrated behind the user's back: non-interactively it is left exactly as it is, and
891
+ // interactively the question is asked plainly before anything is written.
892
+ // ============================================================================================
893
+ line(heading('5/5 — Rooms (ours-cowork)'));
894
+ line(info('Durable mission rooms: a room keeps its own ordered history, and people and agents'));
895
+ line(info('join it as seats. It serves a local web console, and reaches everyone through the'));
896
+ line(info('same broker as the rest of your install.'));
897
+ const goRooms = yes(' Install it?', false);
898
+ if (goRooms) {
899
+ await actSpin(`installing ${spec('cowork')}…`, `npm i -g ${spec('cowork')}`, () => runAsync(NPM, ['i', '-g', spec('cowork')]));
900
+ const roomsStateDir = join(homedir(), '.ours-cowork');
901
+ const cfgPath = coworkConfigPath(process.env, homedir());
902
+ let existingRooms = {};
903
+ try { existingRooms = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
904
+ const restDefault = Number.isInteger(existingRooms.rest?.port) ? existingRooms.rest.port : COWORK_DEFAULT_PORT;
905
+ line('');
906
+ line(info('Rooms serves a console on a loopback port — 127.0.0.1 only, never exposed.'));
907
+ // COWORK_DEFAULT_PORT is in RESERVED_PORTS (so no ours daemon can be handed it),
908
+ // so validate this one against the daemon ports only.
909
+ const roomsPort = (() => {
910
+ let candidate = restDefault;
911
+ for (let attempt = 0; attempt < 3; attempt++) {
912
+ const raw = ask(` Which local port should the Rooms console use? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
913
+ const v = validateDaemonPort(raw, {
914
+ fallback: candidate, isTaken: (p) => (p === restDefault ? false : portTakenSync(p)),
915
+ taken: claimedPorts, reserved: [],
916
+ });
917
+ if (v.ok) return v.port;
918
+ line(warn(`${v.reason}.`));
919
+ if (!interactive) break;
920
+ candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
921
+ line(info(`Suggesting ${candidate} instead.`));
922
+ }
923
+ return candidate;
924
+ })();
925
+ claimPort(roomsPort, 'the Rooms console');
926
+
927
+ // WHICH DAEMON. `undefined` means "leave whatever is there alone" — the answer for an
928
+ // existing embedded install nobody asked to migrate.
929
+ const wasEmbedded = coworkDaemonMode(existingRooms) === 'embedded';
930
+ const hadConfig = existsSync(cfgPath);
931
+ // Ask the BUILD, not the channel: this runs after the install above, so the version
932
+ // read here is the one actually on the machine.
933
+ const coworkVersion = globalVersion('@ours.network/cowork');
934
+ const externalSupported = coworkSupportsExternalDaemon(coworkVersion);
935
+ let roomsDaemon;
936
+ let roomsDaemonLabel;
937
+ if (!externalSupported) {
938
+ // The build we just installed predates cowork's external-daemon mode. Its config
939
+ // is a strict document and its boot fails closed, so writing a selection it cannot
940
+ // honour would break Rooms rather than degrade it.
941
+ line(info(`This Rooms build${coworkVersion ? ` (${coworkVersion})` : ''} hosts its own daemon; pointing it at the shared`));
942
+ line(info(`one needs ${COWORK_EXTERNAL_MIN_VERSION} or newer. Re-run with ${c.cyan('OURS_CHANNEL=nightly')} to get it.`));
943
+ roomsDaemonLabel = 'embedded';
944
+ } else if (hadConfig && wasEmbedded && !interactive) {
945
+ // Fail-closed boot makes this migration a real risk; never do it unasked.
946
+ line(info('Rooms already runs its own embedded daemon — leaving that alone.'));
947
+ line(info(`To point it at this install's daemon, re-run ${c.cyan('ours-install')} in a terminal.`));
948
+ roomsDaemonLabel = 'embedded (unchanged)';
949
+ } else {
950
+ line('');
951
+ const roomsMode = askDaemonMode('Rooms');
952
+ if (roomsMode === 'dedicated') {
953
+ const instance = DEDICATED_INSTANCES.rooms;
954
+ const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
955
+ const port = askDaemonPort('Which local port should the Rooms daemon use?', suggested);
956
+ claimPort(port, 'the dedicated Rooms daemon');
957
+ const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Rooms' });
958
+ roomsDaemon = { endpoint: provisioned.endpoint, stateDir: provisioned.stateDir };
959
+ roomsDaemonLabel = `dedicated daemon ${port}`;
960
+ } else {
961
+ roomsDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir() };
962
+ roomsDaemonLabel = `common daemon ${chosenPort}`;
963
+ line(ok(`Rooms will use the shared daemon on port ${chosenPort}.`));
964
+ }
965
+ }
966
+
967
+ const roomsPlan = planCoworkConfig(existingRooms, {
968
+ brokerUrl: sharedBroker(),
969
+ stateDir: roomsStateDir,
970
+ restPort: roomsPort,
971
+ daemon: roomsDaemon,
972
+ });
973
+ if (roomsPlan.error) {
974
+ // Only reachable if a daemon selection lost half of itself; a half-written block
975
+ // would fail closed at cowork's boot, so refuse rather than write it.
976
+ line(warn(`not changing the Rooms daemon selection — ${roomsPlan.error}.`));
977
+ } else if (roomsPlan.changed) {
978
+ await act(`write ${cfgPath} (console port ${roomsPort}, state ${roomsStateDir}${roomsDaemon ? `, daemon ${roomsDaemon.endpoint}` : ''})`, async () => {
979
+ atomicWriteConfig(cfgPath, roomsPlan.text);
980
+ return { ok: true };
981
+ });
982
+ if (roomsDaemon) line(ok(`Rooms configured to use ${roomsDaemonLabel} (${roomsDaemon.endpoint}, state ${roomsDaemon.stateDir}).`));
983
+ } else {
984
+ line(ok(`Rooms is already configured for this deployment (console port ${roomsPort}) — no change.`));
985
+ }
986
+ const svc = await act('ours-cowork install-service (starts on boot)', async () => run('ours-cowork', ['install-service']));
987
+ if (svc.ok) {
988
+ line(ok(`Rooms ready — console at ${c.cyan(`http://127.0.0.1:${roomsPort}/`)}, sharing your broker. No problems.`));
989
+ } else {
990
+ line(warn(`Rooms installed, but its service didn't start — retry '${c.cyan('ours-cowork install-service')}'.`));
991
+ line(info(`You can also run it in the foreground: '${c.cyan('ours-cowork web')}'.`));
992
+ }
993
+ record({
994
+ key: 'rooms',
995
+ label: 'Rooms (ours-cowork)',
996
+ state: svc.ok ? 'installed' : 'failed',
997
+ version: coworkVersion,
998
+ note: svc.ok ? `console ${roomsPort} · ${roomsDaemonLabel}` : 'ours-cowork install-service failed',
999
+ });
1000
+ } else {
1001
+ line(info('skipped cleanly — re-run ours-install any time to add it.'));
1002
+ record({ key: 'rooms', label: 'Rooms (ours-cowork)', state: 'skipped' });
1003
+ }
1004
+ cont(goRooms);
1005
+
1006
+ // Last guard on the whole topology: no two daemons in this install may share a port.
1007
+ // Each answer was validated as it was given, but only the finished plan proves the set.
1008
+ const portPlan = planPorts(topology);
1009
+ if (!portPlan.ok) {
1010
+ for (const d of portPlan.duplicates) {
1011
+ line(warn(`port ${d.port} ended up claimed by both ${d.labels[0]} and ${d.labels[1]} — one of them will fail to bind.`));
1012
+ }
1013
+ }
1014
+
748
1015
  return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
749
1016
  }
750
1017
 
@@ -823,7 +1090,9 @@ function endScreen({ ttyFd, summary, chosenPort, chosenBroker }) {
823
1090
  const has = (k) => summary.some((r) => r.key === k && (r.state === 'installed' || r.state === 'current'));
824
1091
  if (has('core')) {
825
1092
  const identityDone = has('identity');
826
- const { text, empty } = buildHandoffPrompt({ identity: !identityDone, fleet: has('fleet'), telegram: has('telegram') });
1093
+ const { text, empty } = buildHandoffPrompt({
1094
+ identity: !identityDone, fleet: has('fleet'), telegram: has('telegram'), rooms: has('rooms'),
1095
+ });
827
1096
  if (empty) {
828
1097
  // Nothing left to finish (identity created in-install, no fleet/Telegram). Don't show an empty box.
829
1098
  line('');
package/lib/logic.mjs CHANGED
@@ -28,18 +28,61 @@ export function canonHarnesses(raw) {
28
28
 
29
29
  // ── Release CHANNEL / npm dist-tag selection (owner 2026-07-17) ─────────────────
30
30
  // The installer normally installs everything at @latest (stable). Setting
31
- // OURS_CHANNEL=nightly (or OURS_INSTALL_CHANNEL) makes it install the NIGHTLY tag
32
- // for the packages that HAVE a nightly (mcp, tg-connector, and the harness-plugin
33
- // launchers claude-code/codex/hermes — all lockstep-published to the `nightly` tag),
34
- // but keep @ours.network/fleet at @latest ALWAYS: ours-fleet lives in its own repo
35
- // and publishes NO nightly tag, so `@nightly` there would 404 the whole install.
31
+ // OURS_CHANNEL=nightly (or OURS_INSTALL_CHANNEL) makes it install each package's
32
+ // PRERELEASE dist-tag instead — for the packages that publish one.
33
+ //
34
+ // The prerelease tag is NOT the same string everywhere, which is why this is a
35
+ // per-package map rather than one global tag:
36
+ // · mcp, tg-connector, claude-code, codex, hermes → `nightly` (lockstep-published
37
+ // from this repo by .github/workflows/scripts/bump-versions.sh)
38
+ // · fleet → `nightly` (its own repo,
39
+ // adapt-toolkit/ours-fleet, publishes a nightly dist-tag of its own)
40
+ // · cowork (rooms) → `nightly` (owner decision
41
+ // 2026-08-16: cowork aligns with every other service rather than keeping its
42
+ // historical `next` tag)
43
+ //
44
+ // WHY FLEET FOLLOWS THE CHANNEL NOW. It used to be pinned to @latest with the note
45
+ // "ours-fleet publishes no nightly tag". It does publish one, and the nightly stack
46
+ // needs the fleet build carrying the SDK integration — the same architecture
47
+ // boundary that made a mixed tg-connector fatal applies here. A nightly installer
48
+ // that silently installs stable fleet is exactly the split-brain deployment the
49
+ // channel exists to prevent.
50
+ //
51
+ // WHY COWORK STILL NEEDS ITS OWN ENTRY. It historically published its prerelease
52
+ // line as `next`; the owner's decision on 2026-08-16 is that it aligns with every
53
+ // other service and publishes `nightly` instead. The entry stays because the map,
54
+ // not a hardcoded string, is what makes such a change one line — and because an
55
+ // UNMAPPED package deliberately falls back to `latest` rather than a guessed tag.
56
+ //
57
+ // The nightly channel MUST reach cowork's prerelease line: the external-daemon
58
+ // mode the Rooms step configures ships there (cowork PR #9), so a nightly
59
+ // installer taking `latest` would pair a config carrying a `daemon` block with a
60
+ // build that predates it — the same architecture-boundary mismatch this whole
61
+ // mechanism exists to prevent, pointed at Rooms instead of Telegram.
62
+ //
63
+ // SEQUENCING — load-bearing, and not yet satisfied. As of 2026-08-16 cowork
64
+ // publishes NO `nightly` dist-tag at all (its tags are latest=0.4.0 and
65
+ // next=0.3.7-nightly.20260815.80ea770). `npm i -g @ours.network/cowork@nightly`
66
+ // therefore 404s today, and a 404 fails the WHOLE install. So the nightly
67
+ // installer must not be published until cowork's release flow has actually
68
+ // published a `nightly` artifact carrying PR #9. Verify the tag exists AND that
69
+ // the tarball contains the implementation — never trust the tag alone. See
70
+ // coworkSupportsExternalDaemon for the guard that keeps a build without the mode
71
+ // from ever being handed a daemon block.
36
72
  export const DEFAULT_CHANNEL = 'latest';
37
73
 
38
- // Packages that follow the selected channel (nightly ⇒ @nightly). Short keys map to
39
- // the @ours.network/<key> npm name. NOTE fleet is deliberately ABSENT — it is pinned.
40
- const CHANNEL_TRACKING_PKGS = new Set(['mcp', 'tg-connector', 'claude-code', 'codex', 'hermes']);
41
- // Packages ALWAYS pinned to @latest regardless of channel (no nightly tag exists).
42
- const STABLE_ONLY_PKGS = new Set(['fleet']);
74
+ // Per-package dist-tag by channel. A package absent from this map, or missing a
75
+ // key for the selected channel, installs @latest — this never guesses a tag that
76
+ // might not exist, because a 404 fails the WHOLE install.
77
+ const PKG_CHANNEL_TAGS = {
78
+ mcp: { nightly: 'nightly' },
79
+ 'tg-connector': { nightly: 'nightly' },
80
+ 'claude-code': { nightly: 'nightly' },
81
+ codex: { nightly: 'nightly' },
82
+ hermes: { nightly: 'nightly' },
83
+ fleet: { nightly: 'nightly' },
84
+ cowork: { nightly: 'nightly' }, // aligned with every other service (owner, 2026-08-16)
85
+ };
43
86
 
44
87
  // Normalize a raw channel selection to 'latest' | 'nightly'. Anything unrecognized
45
88
  // (incl. undefined/'') falls back to the installer's OWN channel — never guesses a tag.
@@ -67,14 +110,14 @@ export function isNightlyVersion(version) {
67
110
  return /-nightly\.\d+/.test(String(version || ''));
68
111
  }
69
112
 
70
- // The npm dist-tag to install for one package key under a channel. fleet is ALWAYS
71
- // 'latest'; channel-tracking packages take the channel; anything else defaults to 'latest'.
113
+ // The npm dist-tag to install for one package key under a channel. Looks the key
114
+ // up in PKG_CHANNEL_TAGS; anything unmapped — including an unknown package —
115
+ // falls back to 'latest' rather than inventing a tag that would 404.
72
116
  export function pkgTag(pkgKey, channel = DEFAULT_CHANNEL) {
73
117
  const key = String(pkgKey || '').replace(/^@ours\.network\//, '');
74
- if (STABLE_ONLY_PKGS.has(key)) return 'latest';
75
118
  const ch = resolveChannel(channel);
76
- if (ch === 'nightly' && CHANNEL_TRACKING_PKGS.has(key)) return 'nightly';
77
- return 'latest';
119
+ if (ch === DEFAULT_CHANNEL) return 'latest';
120
+ return PKG_CHANNEL_TAGS[key]?.[ch] ?? 'latest';
78
121
  }
79
122
 
80
123
  // Full `@ours.network/<key>@<tag>` spec for `npm i -g`, honoring the channel.
@@ -83,11 +126,88 @@ export function pkgSpec(pkgKey, channel = DEFAULT_CHANNEL) {
83
126
  return `@ours.network/${key}@${pkgTag(key, channel)}`;
84
127
  }
85
128
 
86
- // The Telegram connector owns 3051 — the installer must never hand a daemon that port.
87
- export const RESERVED_PORTS = [3051];
129
+ // Ports other components in the stack own, which the installer must never hand a
130
+ // daemon: 3051 is the Telegram connector's, 3052 is ours-cowork's loopback console
131
+ // (its config default; see COWORK_DEFAULT_PORT).
132
+ export const RESERVED_PORTS = [3051, 3052];
88
133
  export const DEFAULT_PORT = 3050;
89
134
  export const DEFAULT_BROKER = 'wss://broker1.ours.network';
90
135
 
136
+ // ── Daemon topology: one COMMON daemon, plus optional DEDICATED ones ───────────
137
+ // Every consumer defaults to the common daemon on the common port — that is the
138
+ // backward-compatible answer and what Enter / non-interactive mode picks. A
139
+ // consumer may instead be given its OWN daemon, which needs three things to be
140
+ // genuinely isolated and not merely differently-addressed:
141
+ // · its own PORT (nothing else may bind it)
142
+ // · its own STATE DIRECTORY (the daemon's API token lives there; sharing one
143
+ // state dir between two daemons corrupts both)
144
+ // · its own SERVICE NAME (ours-mcp's boot unit — without a distinct name,
145
+ // `install-service` overwrites the common daemon's
146
+ // unit; see packages/core/src/service-instance.ts)
147
+ // The config file is separate too, since ours-mcp resolves port + stateDir from
148
+ // whatever OURS_CONFIG points at.
149
+ export const DAEMON_MODES = ['common', 'dedicated'];
150
+
151
+ // Fixed instance names, one per consumer that can own a daemon. These are what
152
+ // core validates and turns into `ours-<name>.service`, so they must satisfy its
153
+ // rules (alphanumeric, no separators at the ends).
154
+ export const DEDICATED_INSTANCES = { telegram: 'tg', rooms: 'rooms' };
155
+
156
+ // Where a dedicated daemon's private config + state live. Derived from the
157
+ // instance name so two consumers can never be handed the same directory.
158
+ export function dedicatedDaemonPaths(home, instance) {
159
+ const name = String(instance || '').trim();
160
+ const stateDir = `${home}/.ours-${name}`;
161
+ return { stateDir, configPath: `${stateDir}/config.json`, serviceName: name };
162
+ }
163
+
164
+ // Normalize a daemon-mode answer. Anything unrecognized (including empty and
165
+ // non-interactive) is 'common' — the backward-compatible default.
166
+ export function resolveDaemonMode(raw) {
167
+ const v = String(raw || '').trim().toLowerCase();
168
+ return v === 'dedicated' || v === 'own' || v === 'separate' ? 'dedicated' : 'common';
169
+ }
170
+
171
+ // Validate a port the user picked for a daemon, against the reserved list AND
172
+ // every port this install has already committed to. `isTaken(port)` probes a real
173
+ // bind; `taken` is the set of ports already chosen in THIS run, which a live probe
174
+ // cannot see (nothing is listening on them yet). Returns
175
+ // { ok, port, reason } — ok=false means "ask again", never "silently substitute".
176
+ export function validateDaemonPort(input, { fallback = DEFAULT_PORT, isTaken = () => false, taken = [], reserved = RESERVED_PORTS } = {}) {
177
+ // Stricter than parsePort on purpose: this answer becomes a persisted listen port,
178
+ // so "3.5.1" must be a question repeated, not silently accepted as port 3.
179
+ if (!/^\d+$/.test(String(input ?? '').trim())) {
180
+ return { ok: false, port: fallback, reason: 'that is not a port number between 1 and 65535' };
181
+ }
182
+ const parsed = parsePort(input, fallback);
183
+ if (!parsed.ok) return { ok: false, port: fallback, reason: 'that is not a port number between 1 and 65535' };
184
+ const port = parsed.port;
185
+ if (reserved.includes(port)) {
186
+ return { ok: false, port, reason: `port ${port} is reserved by another part of the stack` };
187
+ }
188
+ if (taken.includes(port)) {
189
+ return { ok: false, port, reason: `port ${port} is already being used by another daemon in this install` };
190
+ }
191
+ if (isTaken(port)) {
192
+ return { ok: false, port, reason: `port ${port} is already in use on this machine` };
193
+ }
194
+ return { ok: true, port, reason: '' };
195
+ }
196
+
197
+ // The whole install's port plan, checked as a set. Returns { ok, duplicates } so
198
+ // the caller can refuse a topology where two daemons would fight over one port
199
+ // even though each looked fine on its own.
200
+ export function planPorts(entries = []) {
201
+ const seen = new Map();
202
+ const duplicates = [];
203
+ for (const { label, port } of entries) {
204
+ if (!Number.isInteger(port)) continue;
205
+ if (seen.has(port)) duplicates.push({ port, labels: [seen.get(port), label] });
206
+ else seen.set(port, label);
207
+ }
208
+ return { ok: duplicates.length === 0, duplicates };
209
+ }
210
+
91
211
  // ── Handing the Telegram connector the ONE shared daemon ───────────────────────
92
212
  // The connector has its OWN config file and never inherits the daemon's. Two
93
213
  // generations of it are in the wild and the installer must satisfy BOTH, because
@@ -161,6 +281,186 @@ export function planTgDaemonConfig(existing, { daemonUrl, daemonStateDir, broker
161
281
  return { changed: true, text: mergeConfig(base, next), previous };
162
282
  }
163
283
 
284
+ // ── Rooms / ours-cowork ────────────────────────────────────────────────────────
285
+ // ours-cowork was a purely standalone daemon: its shipped 0.4.0 bundle has no
286
+ // daemonUrl / daemonStateDir / /api/v1 anywhere and its docs said it "has no
287
+ // dependency on another agent daemon". ours-cowork PR #9 (head 030b71df…) adds an
288
+ // EXTERNAL daemon mode, so Rooms can now answer the same common-vs-dedicated
289
+ // question the Telegram connector does. Its exact contract, as reported:
290
+ //
291
+ // ~/.ours-cowork/config.json carries an OPTIONAL `daemon` block.
292
+ // absent ⇒ EMBEDDED — cowork hosts its own daemon (what every install
293
+ // before PR #9 does, and still the safe answer for one already
294
+ // running that way).
295
+ // present ⇒ { mode: 'external', endpoint: 'http://127.0.0.1:<port>',
296
+ // stateDir: '<absolute ours-daemon state dir>' }
297
+ // External REQUIRES both endpoint and stateDir. cowork never stores or asks for
298
+ // a token — its SDK reads <stateDir>/daemon-token, which is why the state
299
+ // directory is part of the selection rather than derivable from the endpoint.
300
+ // Env equivalents: OURS_COWORK_DAEMON_MODE / _ENDPOINT / _STATE_DIR, and the
301
+ // service unit carries only those — never a token.
302
+ // Boot is FAIL-CLOSED: an unavailable endpoint, a non-ours daemon, or a
303
+ // stateDir that does not match it aborts startup. There is no embedded
304
+ // fallback, so writing this block is a real commitment and must never be done
305
+ // to an install that did not ask for it.
306
+ //
307
+ // NOTE the two different `stateDir` keys. The TOP-LEVEL one is cowork's own
308
+ // private state. `daemon.stateDir` is the OURS daemon's state directory, where
309
+ // that daemon's API token lives. Confusing them fails closed at boot.
310
+ export const COWORK_DEFAULT_PORT = 3052;
311
+ export const COWORK_DAEMON_MODES = ['embedded', 'external'];
312
+
313
+ // Which cowork builds understand the `daemon` block. Its config is a STRICT
314
+ // document, so handing an unknown key to a build that predates PR #9 is not a
315
+ // harmless no-op — and cowork's boot is fail-closed, so the failure surfaces as a
316
+ // Rooms daemon that will not start rather than a warning.
317
+ //
318
+ // The FIRST published cowork that implements the external-daemon mode. Verified
319
+ // against the registry rather than taken on trust:
320
+ // @ours.network/cowork@nightly = 0.4.1-nightly.20260816.4aaf940
321
+ // gitHead 4aaf9406016098704d06b52352f7a38adc2ef160
322
+ // dist.shasum 5a6422409b1203a9bcc6aca33965fe47e9a5c17c
323
+ // depends on @ours.network/sdk 1.3.1; `latest` still 0.4.0
324
+ // and the packed tarball really carries it — dist/daemon.js and dist/cli.js
325
+ // contain the mode enum ["embedded","external"], the endpoint+stateDir pairing
326
+ // check, OURS_COWORK_DAEMON_MODE/_ENDPOINT/_STATE_DIR, and the daemon-token read.
327
+ export const COWORK_EXTERNAL_MIN_VERSION = '0.4.1-nightly.20260816.4aaf940';
328
+
329
+ // Does the cowork build actually on this machine support an external daemon?
330
+ //
331
+ // This is deliberately a VERSION check and not a channel check. A channel gate
332
+ // would answer "yes" for any nightly install, including one made before this
333
+ // version was published — and a `daemon` block handed to a build without the mode
334
+ // meets a strict config and a fail-closed boot, i.e. Rooms that will not start.
335
+ // The version is read after the install, so it describes what is really there.
336
+ // An unreadable version yields -1 below and therefore "no", which keeps Rooms
337
+ // embedded rather than guessing.
338
+ export function coworkSupportsExternalDaemon(installedVersion = '', minVersion = COWORK_EXTERNAL_MIN_VERSION) {
339
+ if (!minVersion) return false; // no published build supports it yet
340
+ return compareVersions(String(installedVersion || ''), minVersion) >= 0;
341
+ }
342
+
343
+ // Semver precedence, enough for a published-release floor: x.y.z numerically,
344
+ // then prerelease rules — a release outranks a prerelease of the same core
345
+ // version, and two prereleases compare identifier by identifier (numeric parts
346
+ // numerically, so nightly.20260815 < nightly.20260816). Returns -1 / 0 / 1, and
347
+ // -1 for anything unparseable, so garbage NEVER claims to be new enough.
348
+ export function compareVersions(a, b) {
349
+ const split = (v) => {
350
+ const m = String(v ?? '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
351
+ return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null;
352
+ };
353
+ const x = split(a);
354
+ const y = split(b);
355
+ if (!x || !y) return -1;
356
+ for (let i = 0; i < 3; i++) {
357
+ if (x.core[i] !== y.core[i]) return x.core[i] > y.core[i] ? 1 : -1;
358
+ }
359
+ if (x.pre === null && y.pre === null) return 0;
360
+ if (x.pre === null) return 1; // 1.0.0 outranks 1.0.0-nightly.1
361
+ if (y.pre === null) return -1;
362
+ const xs = x.pre.split('.');
363
+ const ys = y.pre.split('.');
364
+ for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
365
+ const xi = xs[i];
366
+ const yi = ys[i];
367
+ if (xi === undefined) return -1; // a shorter identifier set is lower
368
+ if (yi === undefined) return 1;
369
+ const xn = /^\d+$/.test(xi);
370
+ const yn = /^\d+$/.test(yi);
371
+ if (xn && yn) {
372
+ if (Number(xi) !== Number(yi)) return Number(xi) > Number(yi) ? 1 : -1;
373
+ } else if (xn !== yn) {
374
+ return xn ? -1 : 1; // numeric identifiers rank below alphanumeric
375
+ } else if (xi !== yi) {
376
+ return xi > yi ? 1 : -1;
377
+ }
378
+ }
379
+ return 0;
380
+ }
381
+
382
+ // Which daemon an existing cowork config is set up for. No block ⇒ embedded.
383
+ export function coworkDaemonMode(existing) {
384
+ const block = existing && typeof existing === 'object' ? existing.daemon : null;
385
+ if (!block || typeof block !== 'object') return 'embedded';
386
+ return block.mode === 'external' ? 'external' : 'embedded';
387
+ }
388
+
389
+ // Build the external `daemon` block. Returns { ok, block, reason } — external is
390
+ // refused without BOTH halves rather than written half-formed, because a partial
391
+ // block fails closed at cowork's boot and the user would only find out then.
392
+ export function coworkDaemonBlock({ endpoint, stateDir } = {}) {
393
+ const e = String(endpoint || '').trim();
394
+ const s = String(stateDir || '').trim();
395
+ if (!e || !s) {
396
+ return { ok: false, block: null, reason: 'an external daemon needs BOTH an endpoint and its state directory' };
397
+ }
398
+ return { ok: true, block: { mode: 'external', endpoint: e, stateDir: s }, reason: '' };
399
+ }
400
+
401
+ // The cowork config file (mirrors its docs/03-configuration.md: OURS_COWORK_CONFIG,
402
+ // else <home>/.ours-cowork/config.json).
403
+ export function coworkConfigPath(env = {}, home = '') {
404
+ return env.OURS_COWORK_CONFIG || `${home}/.ours-cowork/config.json`;
405
+ }
406
+
407
+ // Decide whether cowork's config needs a write, and what to write. Same contract
408
+ // as planTgDaemonConfig: { changed, text, previous }, unchanged ⇒ nothing written,
409
+ // so a re-run is a no-op. Its `rest` block is merged rather than replaced, so an
410
+ // operator's explicit `rest.enabled: false` survives a port update.
411
+ // `daemon` is three-valued on purpose:
412
+ // undefined — do not touch the daemon selection at all (an existing embedded
413
+ // install this run was not asked to migrate)
414
+ // null — EMBEDDED: remove any block, cowork hosts its own daemon again
415
+ // {endpoint, stateDir} — EXTERNAL: point it at that ours daemon
416
+ export function planCoworkConfig(existing, { brokerUrl, stateDir, restPort, daemon } = {}) {
417
+ const base = existing && typeof existing === 'object' ? existing : {};
418
+ const baseRest = base.rest && typeof base.rest === 'object' ? base.rest : {};
419
+ const previous = {
420
+ brokerUrl: typeof base.brokerUrl === 'string' ? base.brokerUrl : '',
421
+ stateDir: typeof base.stateDir === 'string' ? base.stateDir : '',
422
+ restPort: Number.isInteger(baseRest.port) ? baseRest.port : null,
423
+ daemonMode: coworkDaemonMode(base),
424
+ daemonEndpoint: base.daemon?.endpoint ?? '',
425
+ daemonStateDir: base.daemon?.stateDir ?? '',
426
+ };
427
+
428
+ let nextDaemon; // undefined ⇒ leave the block alone
429
+ let daemonChanged = false;
430
+ if (daemon === null) {
431
+ nextDaemon = null;
432
+ daemonChanged = previous.daemonMode !== 'embedded';
433
+ } else if (daemon !== undefined) {
434
+ const built = coworkDaemonBlock(daemon);
435
+ if (!built.ok) return { changed: false, text: '', previous, error: built.reason };
436
+ nextDaemon = built.block;
437
+ daemonChanged = previous.daemonMode !== 'external'
438
+ || previous.daemonEndpoint !== built.block.endpoint
439
+ || previous.daemonStateDir !== built.block.stateDir;
440
+ }
441
+
442
+ const changed = daemonChanged
443
+ || (brokerUrl !== undefined && previous.brokerUrl !== brokerUrl)
444
+ || (stateDir !== undefined && previous.stateDir !== stateDir)
445
+ || (restPort !== undefined && previous.restPort !== restPort);
446
+ if (!changed) return { changed: false, text: '', previous };
447
+
448
+ const patch = { version: 1 };
449
+ if (brokerUrl !== undefined) patch.brokerUrl = brokerUrl;
450
+ if (stateDir !== undefined) patch.stateDir = stateDir;
451
+ if (restPort !== undefined) patch.rest = { ...baseRest, enabled: baseRest.enabled ?? true, port: restPort };
452
+ if (nextDaemon !== undefined) patch.daemon = nextDaemon;
453
+ const merged = mergeConfig(base, patch);
454
+ // mergeConfig keeps every key it is handed; an EMBEDDED selection has to drop
455
+ // the block outright, since `daemon: null` is not the same as no block.
456
+ if (nextDaemon === null) {
457
+ const obj = JSON.parse(merged);
458
+ delete obj.daemon;
459
+ return { changed: true, text: JSON.stringify(obj, null, 2) + '\n', previous };
460
+ }
461
+ return { changed: true, text: merged, previous };
462
+ }
463
+
164
464
  // suggestPort: pick a usable HTTP port. If `desired` is free and not reserved, keep it. Otherwise
165
465
  // scan upward from 3060 (the brief's suggested alternate band) for the first free, non-reserved
166
466
  // port. `isTaken(port)` is injected so this stays pure and testable (real caller probes a bind).
@@ -365,7 +665,7 @@ export function harnessAvailable(status) { return status === 'ok'; }
365
665
  // instruction for a piece they don't have. The human identity is normally created DURING install,
366
666
  // so its step is included ONLY as a fallback (identity: true) when in-install creation was skipped
367
667
  // or failed. Returns { text, empty } — empty is true when there is nothing left to finish.
368
- export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false } = {}) {
668
+ export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false, rooms = false } = {}) {
369
669
  const steps = [];
370
670
  if (identity) {
371
671
  steps.push(
@@ -387,6 +687,13 @@ export function buildHandoffPrompt({ identity = false, fleet = false, telegram =
387
687
  ' give me the invite link to send.',
388
688
  );
389
689
  }
690
+ if (rooms) {
691
+ steps.push(
692
+ 'Set up my first Rooms mission room: ask me what the room is for and what\n' +
693
+ ' to call it, create it, then walk me through inviting the people and\n' +
694
+ ' agents who should have a seat.',
695
+ );
696
+ }
390
697
  if (steps.length === 0) return { text: '', empty: true };
391
698
  const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
392
699
  const text =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.2",
3
+ "version": "0.17.0-nightly.3",
4
4
  "private": false,
5
5
  "description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
6
6
  "type": "module",