@ours.network/install 0.17.0 → 0.18.0-nightly.1

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.
@@ -0,0 +1,158 @@
1
+ // ours-install v3 — the config journal.
2
+ //
3
+ // WHAT THIS IS FOR, IN ONE SENTENCE: v3 could write a config file describing a
4
+ // daemon it then failed to bring up, print one warning line, and go on to say
5
+ // "install complete".
6
+ //
7
+ // The nightly installer does not have that failure. It snapshots every config
8
+ // file before touching it and restores the bytes when the plan does not complete
9
+ // (lib/nightly-install.mjs `snap`/`rollbackSnapshots`), and it says precisely what
10
+ // it could NOT undo. This is that behaviour, carried into v3's shape rather than
11
+ // copied into it.
12
+ //
13
+ // THE LINE THIS DOES NOT CROSS. Package and plugin installs are NOT rolled back.
14
+ // Not because it is hard, but because it is wrong: npm cannot be un-run
15
+ // meaningfully, a newer package is not damage, and nightly draws the line in the
16
+ // same place and says so. What gets restored is exactly the bytes of files this
17
+ // installer rewrote. Everything else is REPORTED.
18
+ //
19
+ // SCOPE IS PER UNIT OF WORK, NOT PER RUN, and that is the one design decision
20
+ // here worth reading twice. v3's rule is that a failed extra never undoes a
21
+ // daemon that came up correctly (lib/orchestrate.mjs `attempt`), so a single
22
+ // run-wide journal restored at the end would fight the architecture: a cowork
23
+ // failure would roll back the daemon's own config. Instead each journal covers
24
+ // ONE write and the step that makes its bytes true — write the connector config,
25
+ // then install its service; if the service does not come up, the config goes
26
+ // back. The pairing is the whole idea, and it is why `snapshot` and `restoreAll`
27
+ // are on an object you hold for the length of one unit of work rather than
28
+ // functions you call anywhere.
29
+
30
+ import { ok, info, warn } from './ui.mjs';
31
+
32
+ /**
33
+ * A journal for one unit of work.
34
+ *
35
+ * `effects.snapshot(path)` and `effects.restore(path, snapshot)` are the seam, so
36
+ * this is testable without a filesystem for the same reason everything else here
37
+ * is. On a dry run nothing was written, so nothing is snapshotted and nothing can
38
+ * be restored — the journal is inert rather than special-cased at each call site.
39
+ */
40
+ export function configJournal(effects, { dryRun = false } = {}) {
41
+ const entries = [];
42
+ return {
43
+ get entries() { return entries.slice(); },
44
+
45
+ /**
46
+ * Record the current bytes of `path` BEFORE it is written. Idempotent per
47
+ * path: the FIRST snapshot is the one that survives, because that is the
48
+ * state the run started from. Snapshotting twice and keeping the second would
49
+ * "restore" to a value this run itself wrote.
50
+ */
51
+ snapshot(path) {
52
+ if (dryRun) return null;
53
+ if (entries.some((e) => e.path === path)) return entries.find((e) => e.path === path).snapshot;
54
+ const snapshot = effects.snapshot(path);
55
+ entries.push({ path, snapshot });
56
+ return snapshot;
57
+ },
58
+
59
+ /**
60
+ * Put every journalled file back, most recent first.
61
+ *
62
+ * A restore that itself fails is reported, never thrown: the caller is already
63
+ * on a failure path, and losing the original error to a second one would hide
64
+ * the thing that actually went wrong. The report says which files went back
65
+ * and which did not, so a partially recovered state is visible rather than
66
+ * implied.
67
+ */
68
+ restoreAll() {
69
+ const restored = [];
70
+ const failed = [];
71
+ for (const entry of entries.slice().reverse()) {
72
+ try {
73
+ effects.restore(entry.path, entry.snapshot);
74
+ // A write that RETURNED is not a file that HOLDS. Read the bytes back.
75
+ const mismatch = verifyRestored(effects, entry);
76
+ if (mismatch) failed.push({ path: entry.path, reason: mismatch });
77
+ else restored.push({ path: entry.path, existed: entry.snapshot?.exists !== false });
78
+ } catch (error) {
79
+ failed.push({ path: entry.path, reason: error instanceof Error ? error.message : String(error) });
80
+ }
81
+ }
82
+ entries.length = 0;
83
+ return { restored, failed };
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Did the restore actually take? Returns null when it did, or the reason it did not.
90
+ *
91
+ * WHY THIS EXISTS. `restore` returning is evidence that a call completed, not that
92
+ * a file holds the bytes it was given. A full disk, a read-only mount, a rename
93
+ * that lands somewhere else, an editor holding the inode — each of those can let
94
+ * the write return and leave the old contents in place. Reporting on the call
95
+ * rather than on the state is how a rollback comes to LIE, and a rollback that
96
+ * lies is worse than one that admits it failed: the operator is told the machine
97
+ * is back where it was and stops looking.
98
+ *
99
+ * The read-back seam is `effects.snapshot`, the same one used to record the bytes
100
+ * in the first place — it already returns exactly the shape being compared, so
101
+ * this needs no second seam and no second notion of what a file's state is.
102
+ *
103
+ * Its own failure is a mismatch, not an exception: if the file cannot be read
104
+ * after being written, that is precisely the case this check exists to catch.
105
+ */
106
+ function verifyRestored(effects, entry) {
107
+ const expected = entry.snapshot;
108
+ let actual;
109
+ try {
110
+ actual = effects.snapshot(entry.path);
111
+ } catch (error) {
112
+ return `restored, but the file could not be read back to confirm it (${error instanceof Error ? error.message : String(error)})`;
113
+ }
114
+ const shouldExist = expected?.exists !== false;
115
+ if (!shouldExist) {
116
+ return actual?.exists ? 'this run created it and the removal did not take — the file is still there' : null;
117
+ }
118
+ if (!actual?.exists) return 'the restore reported success but the file is not there';
119
+ if (actual.text !== expected.text) return 'the restore reported success but the bytes on disk are not the previous ones';
120
+ // Bytes first, permissions second: the contents are back either way, so this
121
+ // must not be reported as "could not restore the config".
122
+ if (expected.mode !== undefined && actual.mode !== undefined && actual.mode !== expected.mode) {
123
+ return `contents restored, but the permissions are ${fmtMode(actual.mode)} and were ${fmtMode(expected.mode)} — check them before re-running`;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ const fmtMode = (mode) => `0${(mode & 0o777).toString(8).padStart(3, '0')}`;
129
+
130
+ /**
131
+ * The report, and it is half the feature.
132
+ *
133
+ * A rollback nobody is told about is indistinguishable from a run that did
134
+ * nothing. Three separate facts, each stated plainly:
135
+ *
136
+ * · which files were put back, and whether "back" meant deleting one this run
137
+ * created — because "restored" and "removed the file we made" are different
138
+ * things to read on a screen;
139
+ * · which could not be put back, if any;
140
+ * · that completed package installs were NOT rolled back. This last line is the
141
+ * honest boundary, and it is nightly's wording rather than a new one.
142
+ */
143
+ export function reportRollback(effects, outcome, { packagesInstalled = false } = {}) {
144
+ const { restored = [], failed = [] } = outcome ?? {};
145
+ if (restored.length === 0 && failed.length === 0) return false;
146
+ for (const item of restored) {
147
+ effects.out(item.existed
148
+ ? ok(`rolled back ${item.path} to its previous contents`)
149
+ : ok(`removed ${item.path} — this run created it and did not finish`));
150
+ }
151
+ for (const item of failed) {
152
+ effects.out(warn(`could NOT roll back ${item.path}: ${item.reason} — inspect it before re-running`));
153
+ }
154
+ if (packagesInstalled) {
155
+ effects.out(info('completed package installs were not rolled back — a newer package is not damage, and npm cannot be un-run'));
156
+ }
157
+ return true;
158
+ }
package/lib/logic.mjs CHANGED
@@ -26,37 +26,98 @@ export function canonHarnesses(raw) {
26
26
  return { names, unknown };
27
27
  }
28
28
 
29
- // ── Release CHANNEL / npm dist-tag selection (owner 2026-07-17) ─────────────────
29
+ // ── Release CHANNEL / npm dist-tag selection ───────────────────────────────────
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` (cowork aligns
41
+ // 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 decision 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. 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
85
+ };
43
86
 
44
87
  // Normalize a raw channel selection to 'latest' | 'nightly'. Anything unrecognized
45
- // (incl. undefined/'') falls back to the safe default 'latest' — never guesses a tag.
46
- export function resolveChannel(raw) {
88
+ // (incl. undefined/'') falls back to the installer's OWN channel — never guesses a tag.
89
+ //
90
+ // WHY THE INSTALLER'S OWN VERSION IS A CHANNEL SIGNAL. `@ours.network/install` is
91
+ // published on both dist-tags from the same lockstep bump (.github/workflows/scripts/
92
+ // bump-versions.sh), so a nightly build carries a `-nightly.N` version and a stable
93
+ // build does not. Without this, `npm i -g @ours.network/install@nightly && ours-install`
94
+ // installed the nightly INSTALLER but @latest for everything it installs — which since
95
+ // tg-connector 0.3.3-nightly.1 is not merely older but a DIFFERENT ARCHITECTURE (0.3.2
96
+ // hosts its own ADAPT wrapper; the nightly attaches to the shared daemon over /api/v1,
97
+ // which only the SDK-based daemon serves). Mixing the two tags across that boundary is
98
+ // exactly the split-brain deployment this must not produce. A stable installer stays on
99
+ // @latest and can never consume a nightly; an explicit OURS_CHANNEL always wins over both.
100
+ export function resolveChannel(raw, selfVersion = '') {
47
101
  const v = String(raw || '').trim().toLowerCase();
48
102
  if (v === 'nightly' || v === 'prerelease' || v === 'next') return 'nightly';
49
- return DEFAULT_CHANNEL; // 'latest' and everything else
103
+ if (v === 'latest' || v === 'stable') return DEFAULT_CHANNEL;
104
+ if (v) return DEFAULT_CHANNEL; // unrecognized: never guess, never inherit
105
+ return isNightlyVersion(selfVersion) ? 'nightly' : DEFAULT_CHANNEL;
50
106
  }
51
107
 
52
- // The npm dist-tag to install for one package key under a channel. fleet is ALWAYS
53
- // 'latest'; channel-tracking packages take the channel; anything else defaults to 'latest'.
108
+ // A published nightly carries the `-nightly.N` prerelease suffix the bump script writes.
109
+ export function isNightlyVersion(version) {
110
+ return /-nightly\.\d+/.test(String(version || ''));
111
+ }
112
+
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.
54
116
  export function pkgTag(pkgKey, channel = DEFAULT_CHANNEL) {
55
117
  const key = String(pkgKey || '').replace(/^@ours\.network\//, '');
56
- if (STABLE_ONLY_PKGS.has(key)) return 'latest';
57
118
  const ch = resolveChannel(channel);
58
- if (ch === 'nightly' && CHANNEL_TRACKING_PKGS.has(key)) return 'nightly';
59
- return 'latest';
119
+ if (ch === DEFAULT_CHANNEL) return 'latest';
120
+ return PKG_CHANNEL_TAGS[key]?.[ch] ?? 'latest';
60
121
  }
61
122
 
62
123
  // Full `@ours.network/<key>@<tag>` spec for `npm i -g`, honoring the channel.
@@ -65,11 +126,266 @@ export function pkgSpec(pkgKey, channel = DEFAULT_CHANNEL) {
65
126
  return `@ours.network/${key}@${pkgTag(key, channel)}`;
66
127
  }
67
128
 
68
- // The Telegram connector owns 3051 — the installer must never hand a daemon that port.
69
- 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];
70
133
  export const DEFAULT_PORT = 3050;
71
134
  export const DEFAULT_BROKER = 'wss://broker1.ours.network';
72
135
 
136
+ // ── Handing the Telegram connector the ONE shared daemon ───────────────────────
137
+ // The connector has its OWN config file and never inherits the daemon's. Two
138
+ // generations of it are in the wild and the installer must satisfy BOTH, because
139
+ // which one gets installed is a channel decision (see pkgTag):
140
+ //
141
+ // @nightly (>=0.3.3-nightly.1) — SDK-based. It ATTACHES to an already-running
142
+ // ours daemon over /api/v1 and has NO broker at all. It selects the daemon
143
+ // through @ours.network/sdk's resolveDaemonConfig, whose behaviour we verified
144
+ // against the published SDK 0.1.2 (the version the nightly pins):
145
+ // · with no overrides it reports configPath: null — the daemon's
146
+ // ~/.ours/config.json is NEVER read implicitly, so a daemon on any port
147
+ // other than the built-in 3050 is simply MISSED.
148
+ // · pointing OURS_CONFIG at that file, or passing an endpoint alone, throws
149
+ // INCOHERENT_SELECTION: "the endpoint was selected … but the state
150
+ // directory is the built-in default" — a deliberate credential-disclosure
151
+ // guard that refuses BEFORE any token is read.
152
+ // · endpoint AND state dir together resolve cleanly.
153
+ // So the ONLY correct contract is to give it BOTH daemonUrl and daemonStateDir.
154
+ //
155
+ // @latest (<=0.3.2) — the older self-hosting ADAPT wrapper. It ignores
156
+ // daemonUrl/daemonStateDir and meets the daemon at a BROKER instead, so its
157
+ // brokerUrl must match the daemon's or the two can never see each other.
158
+ //
159
+ // Writing all three keys satisfies whichever generation is installed: each reads
160
+ // only the keys it understands and ignores the rest. This matters at INSTALL time
161
+ // specifically, because `ours-tg-connector install-service` BAKES its resolved
162
+ // values into the service unit as environment variables, and env outranks the
163
+ // config file forever after — a divergence created here can never be repaired by
164
+ // editing that file.
165
+
166
+ // The daemon's loopback endpoint. The daemon always binds 127.0.0.1 (never a
167
+ // hostname), so this is the address the connector must be given.
168
+ export function daemonEndpoint(port) {
169
+ return `http://127.0.0.1:${port}`;
170
+ }
171
+
172
+ // The broker the whole deployment shares, for a <=0.3.2 connector.
173
+ // Precedence: what the user chose in THIS run > what the running daemon actually
174
+ // resolved (`ours daemon status`, which accounts for the selected config) > what
175
+ // the daemon's config file says > the built-in default (identical to the daemon's
176
+ // DEFAULT_CONFIG.brokerUrl, so "no answer anywhere" still agrees).
177
+ export function resolveSharedBroker({ chosenBroker, statusBroker, configBroker } = {}) {
178
+ for (const candidate of [chosenBroker, statusBroker, configBroker]) {
179
+ const v = validateBroker(candidate ?? '');
180
+ if (!v.empty && v.ok) return v.value;
181
+ }
182
+ return DEFAULT_BROKER;
183
+ }
184
+
185
+ // The Telegram connector's own config file (mirrors its src/config.ts: OURS_TG_CONFIG,
186
+ // else <home>/.ours-telegram/config.json — a FIXED location, independent of its stateDir).
187
+ export function tgConfigPath(env = {}, home = '') {
188
+ return env.OURS_TG_CONFIG || `${home}/.ours-telegram/config.json`;
189
+ }
190
+
191
+ // Decide whether the connector's config needs a write, and what to write. Returns
192
+ // { changed, text, previous } — `changed` is false when the file already names this
193
+ // exact daemon and broker, so an idempotent re-run touches nothing. Unrelated keys
194
+ // the user or the connector added (bot tokens, STT settings) are preserved.
195
+ export function planTgDaemonConfig(existing, { daemonUrl, daemonStateDir, brokerUrl } = {}) {
196
+ const base = existing && typeof existing === 'object' ? existing : {};
197
+ const str = (v) => (typeof v === 'string' ? v : '');
198
+ const previous = {
199
+ daemonUrl: str(base.daemonUrl),
200
+ daemonStateDir: str(base.daemonStateDir),
201
+ brokerUrl: str(base.brokerUrl),
202
+ };
203
+ const next = { daemonUrl, daemonStateDir, brokerUrl };
204
+ const changed = Object.entries(next).some(([k, v]) => v !== undefined && previous[k] !== v);
205
+ if (!changed) return { changed: false, text: '', previous };
206
+ return { changed: true, text: mergeConfig(base, next), previous };
207
+ }
208
+
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:
215
+ //
216
+ // ~/.ours-cowork/config.json carries an OPTIONAL `daemon` block.
217
+ // absent ⇒ EMBEDDED — cowork hosts its own daemon (what every install
218
+ // before PR #9 does, and still the safe answer for one already
219
+ // running that way).
220
+ // present ⇒ { mode: 'external', endpoint: 'http://127.0.0.1:<port>',
221
+ // stateDir: '<absolute ours-daemon state dir>' }
222
+ // External REQUIRES both endpoint and stateDir. cowork never stores or asks for
223
+ // a token — its SDK reads <stateDir>/daemon-token, which is why the state
224
+ // directory is part of the selection rather than derivable from the endpoint.
225
+ // Env equivalents: OURS_COWORK_DAEMON_MODE / _ENDPOINT / _STATE_DIR, and the
226
+ // service unit carries only those — never a token.
227
+ // Boot is FAIL-CLOSED: an unavailable endpoint, a non-ours daemon, or a
228
+ // stateDir that does not match it aborts startup. There is no embedded
229
+ // fallback, so writing this block is a real commitment and must never be done
230
+ // to an install that did not ask for it.
231
+ //
232
+ // NOTE the two different `stateDir` keys. The TOP-LEVEL one is cowork's own
233
+ // private state. `daemon.stateDir` is the OURS daemon's state directory, where
234
+ // that daemon's API token lives. Confusing them fails closed at boot.
235
+ export const COWORK_DEFAULT_PORT = 3052;
236
+ export const COWORK_DAEMON_MODES = ['embedded', 'external'];
237
+
238
+ // 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
240
+ // harmless no-op — and cowork's boot is fail-closed, so the failure surfaces as a
241
+ // Rooms daemon that will not start rather than a warning.
242
+ //
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.
252
+ export const COWORK_EXTERNAL_MIN_VERSION = '0.4.1-nightly.20260816.4aaf940';
253
+
254
+ // Does the cowork build actually on this machine support an external daemon?
255
+ //
256
+ // This is deliberately a VERSION check and not a channel check. A channel gate
257
+ // would answer "yes" for any nightly install, including one made before this
258
+ // version was published — and a `daemon` block handed to a build without the mode
259
+ // meets a strict config and a fail-closed boot, i.e. Rooms that will not start.
260
+ // The version is read after the install, so it describes what is really there.
261
+ // An unreadable version yields -1 below and therefore "no", which keeps Rooms
262
+ // embedded rather than guessing.
263
+ export function coworkSupportsExternalDaemon(installedVersion = '', minVersion = COWORK_EXTERNAL_MIN_VERSION) {
264
+ if (!minVersion) return false; // no published build supports it yet
265
+ return compareVersions(String(installedVersion || ''), minVersion) >= 0;
266
+ }
267
+
268
+ // Semver precedence, enough for a published-release floor: x.y.z numerically,
269
+ // then prerelease rules — a release outranks a prerelease of the same core
270
+ // version, and two prereleases compare identifier by identifier (numeric parts
271
+ // numerically, so nightly.20260815 < nightly.20260816). Returns -1 / 0 / 1, and
272
+ // -1 for anything unparseable, so garbage NEVER claims to be new enough.
273
+ export function compareVersions(a, b) {
274
+ const split = (v) => {
275
+ const m = String(v ?? '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
276
+ return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null;
277
+ };
278
+ const x = split(a);
279
+ const y = split(b);
280
+ if (!x || !y) return -1;
281
+ for (let i = 0; i < 3; i++) {
282
+ if (x.core[i] !== y.core[i]) return x.core[i] > y.core[i] ? 1 : -1;
283
+ }
284
+ if (x.pre === null && y.pre === null) return 0;
285
+ if (x.pre === null) return 1; // 1.0.0 outranks 1.0.0-nightly.1
286
+ if (y.pre === null) return -1;
287
+ const xs = x.pre.split('.');
288
+ const ys = y.pre.split('.');
289
+ for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
290
+ const xi = xs[i];
291
+ const yi = ys[i];
292
+ if (xi === undefined) return -1; // a shorter identifier set is lower
293
+ if (yi === undefined) return 1;
294
+ const xn = /^\d+$/.test(xi);
295
+ const yn = /^\d+$/.test(yi);
296
+ if (xn && yn) {
297
+ if (Number(xi) !== Number(yi)) return Number(xi) > Number(yi) ? 1 : -1;
298
+ } else if (xn !== yn) {
299
+ return xn ? -1 : 1; // numeric identifiers rank below alphanumeric
300
+ } else if (xi !== yi) {
301
+ return xi > yi ? 1 : -1;
302
+ }
303
+ }
304
+ return 0;
305
+ }
306
+
307
+ // Which daemon an existing cowork config is set up for. No block ⇒ embedded.
308
+ export function coworkDaemonMode(existing) {
309
+ const block = existing && typeof existing === 'object' ? existing.daemon : null;
310
+ if (!block || typeof block !== 'object') return 'embedded';
311
+ return block.mode === 'external' ? 'external' : 'embedded';
312
+ }
313
+
314
+ // Build the external `daemon` block. Returns { ok, block, reason } — external is
315
+ // refused without BOTH halves rather than written half-formed, because a partial
316
+ // block fails closed at cowork's boot and the user would only find out then.
317
+ export function coworkDaemonBlock({ endpoint, stateDir } = {}) {
318
+ const e = String(endpoint || '').trim();
319
+ const s = String(stateDir || '').trim();
320
+ if (!e || !s) {
321
+ return { ok: false, block: null, reason: 'an external daemon needs BOTH an endpoint and its state directory' };
322
+ }
323
+ return { ok: true, block: { mode: 'external', endpoint: e, stateDir: s }, reason: '' };
324
+ }
325
+
326
+ // The cowork config file (mirrors its docs/03-configuration.md: OURS_COWORK_CONFIG,
327
+ // else <home>/.ours-cowork/config.json).
328
+ export function coworkConfigPath(env = {}, home = '') {
329
+ return env.OURS_COWORK_CONFIG || `${home}/.ours-cowork/config.json`;
330
+ }
331
+
332
+ // Decide whether cowork's config needs a write, and what to write. Same contract
333
+ // as planTgDaemonConfig: { changed, text, previous }, unchanged ⇒ nothing written,
334
+ // so a re-run is a no-op. Its `rest` block is merged rather than replaced, so an
335
+ // operator's explicit `rest.enabled: false` survives a port update.
336
+ // `daemon` is three-valued on purpose:
337
+ // undefined — do not touch the daemon selection at all (an existing embedded
338
+ // install this run was not asked to migrate)
339
+ // null — EMBEDDED: remove any block, cowork hosts its own daemon again
340
+ // {endpoint, stateDir} — EXTERNAL: point it at that ours daemon
341
+ export function planCoworkConfig(existing, { brokerUrl, stateDir, restPort, daemon } = {}) {
342
+ const base = existing && typeof existing === 'object' ? existing : {};
343
+ const baseRest = base.rest && typeof base.rest === 'object' ? base.rest : {};
344
+ const previous = {
345
+ brokerUrl: typeof base.brokerUrl === 'string' ? base.brokerUrl : '',
346
+ stateDir: typeof base.stateDir === 'string' ? base.stateDir : '',
347
+ restPort: Number.isInteger(baseRest.port) ? baseRest.port : null,
348
+ daemonMode: coworkDaemonMode(base),
349
+ daemonEndpoint: base.daemon?.endpoint ?? '',
350
+ daemonStateDir: base.daemon?.stateDir ?? '',
351
+ };
352
+
353
+ let nextDaemon; // undefined ⇒ leave the block alone
354
+ let daemonChanged = false;
355
+ if (daemon === null) {
356
+ nextDaemon = null;
357
+ daemonChanged = previous.daemonMode !== 'embedded';
358
+ } else if (daemon !== undefined) {
359
+ const built = coworkDaemonBlock(daemon);
360
+ if (!built.ok) return { changed: false, text: '', previous, error: built.reason };
361
+ nextDaemon = built.block;
362
+ daemonChanged = previous.daemonMode !== 'external'
363
+ || previous.daemonEndpoint !== built.block.endpoint
364
+ || previous.daemonStateDir !== built.block.stateDir;
365
+ }
366
+
367
+ const changed = daemonChanged
368
+ || (brokerUrl !== undefined && previous.brokerUrl !== brokerUrl)
369
+ || (stateDir !== undefined && previous.stateDir !== stateDir)
370
+ || (restPort !== undefined && previous.restPort !== restPort);
371
+ if (!changed) return { changed: false, text: '', previous };
372
+
373
+ const patch = { version: 1 };
374
+ if (brokerUrl !== undefined) patch.brokerUrl = brokerUrl;
375
+ if (stateDir !== undefined) patch.stateDir = stateDir;
376
+ if (restPort !== undefined) patch.rest = { ...baseRest, enabled: baseRest.enabled ?? true, port: restPort };
377
+ if (nextDaemon !== undefined) patch.daemon = nextDaemon;
378
+ const merged = mergeConfig(base, patch);
379
+ // mergeConfig keeps every key it is handed; an EMBEDDED selection has to drop
380
+ // the block outright, since `daemon: null` is not the same as no block.
381
+ if (nextDaemon === null) {
382
+ const obj = JSON.parse(merged);
383
+ delete obj.daemon;
384
+ return { changed: true, text: JSON.stringify(obj, null, 2) + '\n', previous };
385
+ }
386
+ return { changed: true, text: merged, previous };
387
+ }
388
+
73
389
  // suggestPort: pick a usable HTTP port. If `desired` is free and not reserved, keep it. Otherwise
74
390
  // scan upward from 3060 (the brief's suggested alternate band) for the first free, non-reserved
75
391
  // port. `isTaken(port)` is injected so this stays pure and testable (real caller probes a bind).
@@ -201,7 +517,7 @@ export function parseVersion(text) {
201
517
  return m ? m[0] : '';
202
518
  }
203
519
 
204
- // parseStatus: read the daemon's RESOLVED broker + port out of `ours-mcp status` output, so we
520
+ // parseStatus: read the daemon's RESOLVED broker + port out of `ours daemon status` output, so we
205
521
  // prompt with what the daemon is actually using rather than a hardcoded guess. Returns
206
522
  // { broker, port } with either field null when the line isn't present (daemon stopped / older
207
523
  // build). Lines look like: " broker: wss://broker1.ours.network" and
@@ -209,7 +525,10 @@ export function parseVersion(text) {
209
525
  export function parseStatus(text) {
210
526
  const s = String(text || '');
211
527
  const bm = s.match(/^\s*broker:\s*(\S+)/m);
212
- const pm = s.match(/url:\s*https?:\/\/[^:\s/]+:(\d+)/i);
528
+ // `api:` is the current line and `url:` the historical one. Both are matched
529
+ // because a newly installed CLI can be asked to report on an older running
530
+ // daemon, and a parser that knows only today's wording reads that as "no port".
531
+ const pm = s.match(/(?:api|url):\s*https?:\/\/[^:\s/]+:(\d+)/i);
213
532
  return {
214
533
  broker: bm ? bm[1] : null,
215
534
  port: pm ? Number.parseInt(pm[1], 10) : null,
@@ -253,7 +572,7 @@ export function detectPlatform({ platform, release = '', env = {} } = {}) {
253
572
  // tell the user plainly + how to fix, and ALWAYS still offer a manual path
254
573
  // 'unsafe' — on PATH but the probe failed/looked wrong → don't auto-drive; offer manual path
255
574
  // 'absent' — genuinely not installed → this harness is skipped (with a note)
256
- // The golden rule (owner edit #3): 'alias'/'unsafe'/'absent' NEVER dead-end — the caller always
575
+ // The golden rule: 'alias'/'unsafe'/'absent' NEVER dead-end — the caller always
257
576
  // prints a manual-install path so the component still gets installed.
258
577
  export function classifyHarnessProbe({ onPath, versionOk, timedOut, shellType = '' } = {}) {
259
578
  if (versionOk) return { status: 'ok', detail: 'real program' };
@@ -274,7 +593,7 @@ export function harnessAvailable(status) { return status === 'ok'; }
274
593
  // instruction for a piece they don't have. The human identity is normally created DURING install,
275
594
  // so its step is included ONLY as a fallback (identity: true) when in-install creation was skipped
276
595
  // or failed. Returns { text, empty } — empty is true when there is nothing left to finish.
277
- export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false } = {}) {
596
+ export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false, rooms = false } = {}) {
278
597
  const steps = [];
279
598
  if (identity) {
280
599
  steps.push(
@@ -296,6 +615,13 @@ export function buildHandoffPrompt({ identity = false, fleet = false, telegram =
296
615
  ' give me the invite link to send.',
297
616
  );
298
617
  }
618
+ if (rooms) {
619
+ steps.push(
620
+ 'Set up my first Rooms mission room: ask me what the room is for and what\n' +
621
+ ' to call it, create it, then walk me through inviting the people and\n' +
622
+ ' agents who should have a seat.',
623
+ );
624
+ }
299
625
  if (steps.length === 0) return { text: '', empty: true };
300
626
  const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
301
627
  const text =