@ours.network/install 0.17.0-nightly.2 → 0.17.0-nightly.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -30
- package/install.mjs +363 -83
- package/lib/logic.mjs +325 -18
- package/lib/nightly-install.mjs +694 -0
- package/lib/nightly-uninstall.mjs +373 -0
- package/lib/profiles.mjs +501 -0
- package/package.json +1 -1
- package/uninstall.mjs +15 -2
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
|
|
32
|
-
// for the packages that
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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
|
-
//
|
|
39
|
-
// the
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
71
|
-
//
|
|
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 ===
|
|
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
|
-
//
|
|
87
|
-
|
|
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 =
|