@ours.network/install 0.17.0-nightly.11 → 0.17.0-nightly.13

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.
@@ -23,8 +23,12 @@ import { pkgSpec, resolveChannel } from './logic.mjs';
23
23
  // effects.installedVersion('@ours.network/mcp@nightly') return null forever,
24
24
  // which fails the cowork version floor CLOSED and blanks the version column —
25
25
  // a silent regression that looks like "cowork is too old".
26
+ // `required` is not a stronger default — it is the absence of a choice. The MCP
27
+ // package IS the daemon now (the daemon phase installs and starts it), so an
28
+ // operator who declined it would have no daemon at all, which is not a decision
29
+ // anyone means to make. Declining has to be impossible rather than discouraged.
26
30
  export const COMPONENTS = [
27
- { key: 'mcp', label: 'MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true },
31
+ { key: 'mcp', label: 'ours daemon + MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
28
32
  { key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: false },
29
33
  { key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
30
34
  ];
@@ -114,7 +118,12 @@ export function planComponentSelection({ answers = {}, installed = {}, assumeYes
114
118
  return COMPONENTS.map((component) => {
115
119
  const already = installed[component.key] === true;
116
120
  const answer = assumeYes ? component.default : answers[component.key];
117
- const wanted = answer === undefined ? (already || component.default) : answer === true;
121
+ // A required component ignores the answer entirely, including an explicit no.
122
+ // The daemon phase has already installed and started it by the time this runs;
123
+ // "skip" here would only produce a screen that contradicts the machine.
124
+ const wanted = component.required
125
+ ? true
126
+ : (answer === undefined ? (already || component.default) : answer === true);
118
127
  return {
119
128
  ...component,
120
129
  already,
package/lib/extras.mjs CHANGED
@@ -333,10 +333,19 @@ export function planVoice({
333
333
  // The beat. Only owed when the config actually changed: an unchanged config
334
334
  // is not a reason to bounce a daemon somebody else may be using.
335
335
  restartOwed: Boolean(configChanged),
336
- restart: configChanged && config ? ['ours', 'daemon', 'restart', '--config', config] : null,
336
+ // ours-mcp's restart, because ours-mcp is the daemon now. NOTE FOR WHOEVER
337
+ // TOUCHES THIS NEXT — the reason the installer owns this beat at all may have
338
+ // just evaporated: cmdVoiceSetup computes `managed = runningPid() !== null`
339
+ // from ours-mcp's OWN pid record, and an ours-mcp daemon HAS one. Under the
340
+ // SDK-CLI daemon it never did, which is why the restart had no owner and the
341
+ // installer took it. If voice-setup now classifies the daemon as managed it
342
+ // will run its own restart protocol, and this becomes a second restart rather
343
+ // than the only one. Not changed here: that is a behaviour question for
344
+ // packages/core, not a rename.
345
+ restart: configChanged && config ? ['ours-mcp', 'restart'] : null,
337
346
  // Never rolls the daemon back: voice-setup leaves the prior config intact on
338
347
  // its own failure path, so the recovery is a retry, not an undo.
339
- retryHint: config ? `ours daemon restart --config ${config}` : null,
348
+ retryHint: config ? `OURS_CONFIG=${config} ours-mcp restart` : null,
340
349
  port: Number.isInteger(port) ? port : null,
341
350
  };
342
351
  }
@@ -139,7 +139,12 @@ export async function runUninstall(argv, effects) {
139
139
  readJson: effects.readJson,
140
140
  readText: effects.readText,
141
141
  exists: effects.exists,
142
- cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null,
142
+ // EITHER record proves a managed daemon: ours-cli-daemon.json is what
143
+ // `ours daemon start` wrote yesterday, daemon.pid is what `ours-mcp start`
144
+ // writes today. Reading only one makes an uninstall on the other kind decline
145
+ // to stop a daemon it could have stopped.
146
+ cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null
147
+ || effects.readText(join(dir, 'daemon.pid')) !== null,
143
148
  otherStateDirsWithConfig: effects.knownStateDirs(),
144
149
  typedConfirmation: null,
145
150
  explicitHarnessSelection: contract.engaged,
@@ -29,7 +29,7 @@ import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './p
29
29
  import {
30
30
  COMPONENTS,
31
31
  planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
32
- tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec,
32
+ tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec, componentByKey,
33
33
  } from './components.mjs';
34
34
  import { planHarnessPlugins, planFleet, planVoice, buildHandoffPromptV3, restartHints } from './extras.mjs';
35
35
  import { summarizeRun } from './rerun.mjs';
@@ -176,6 +176,7 @@ export async function runDaemonPhase(args, effects) {
176
176
  portExplicit: args.portExplicit,
177
177
  probe: effects.probe,
178
178
  readJson: effects.readJson,
179
+ readText: effects.readText,
179
180
  isTaken: effects.isTaken,
180
181
  });
181
182
 
@@ -216,8 +217,22 @@ export async function runDaemonPhase(args, effects) {
216
217
 
217
218
  const steps = [];
218
219
 
219
- await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
220
- steps.push({ id: 'cli', changed: true, packageRefresh: true });
220
+ // THE DAEMON IS ours-mcp, SO ITS PACKAGE COMES FIRST.
221
+ //
222
+ // This used to install @ours.network/cli, because the daemon used to be
223
+ // `ours daemon serve`. That daemon does not mount /mcp — the SDK serves that
224
+ // route only when a host supplies an `mcp` option, and @ours.network/cli calls
225
+ // startDaemon() with no arguments — so every MCP client, including every harness
226
+ // session through `ours-mcp proxy`, got the daemon's own 404. The one
227
+ // MCP-capable daemon in the stack is ours-mcp's, which supplies the server
228
+ // factory, the transport constructor and the notification sink
229
+ // (packages/core/src/serve.ts).
230
+ //
231
+ // The order therefore inverts: the MCP package was a COMPONENT installed after
232
+ // this phase, and it is now the thing this phase starts.
233
+ const daemonPkg = componentSpec(componentByKey('mcp'), args.channel);
234
+ await perform(effects, args.dryRun, `ours daemon installed (npm i -g ${daemonPkg})`, () => effects.run('npm', ['i', '-g', daemonPkg]));
235
+ steps.push({ id: 'daemon-package', changed: true, packageRefresh: true });
221
236
 
222
237
  // The config file — merged, never rewritten, and untouched when it already
223
238
  // matches. No provenance marker is written: the owner ruled that --purge works
@@ -249,11 +264,18 @@ export async function runDaemonPhase(args, effects) {
249
264
 
250
265
  try {
251
266
  if (creating) {
252
- await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
267
+ // `start`, not `serve`: start backgrounds the daemon and polls until the port
268
+ // is open, which is the same observable readiness the old path had. `serve`
269
+ // runs in the foreground and would never return.
270
+ //
271
+ // Selected by ENVIRONMENT rather than flags — ours-mcp reads OURS_CONFIG /
272
+ // OURS_PORT / OURS_STATE_DIR — and daemonEnv is the one place that pair is
273
+ // built, so the daemon this run created is the daemon that starts.
274
+ await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours-mcp', ['start'], { env: daemonEnv(dir, target.port) }));
253
275
  steps.push({ id: 'start', changed: true });
254
276
  }
255
277
 
256
- const service = await runServicePhase(args, effects, dir);
278
+ const service = await runServicePhase(args, effects, dir, target.port);
257
279
  if (service.refused) {
258
280
  // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
259
281
  // unit file stops the run just as a failed start does, and it stops it with
@@ -274,7 +296,7 @@ export async function runDaemonPhase(args, effects) {
274
296
  // The nightly flow re-runs `start` and, crucially, tells the two outcomes
275
297
  // apart: "the service failed but the daemon is back" is a bad evening, and "the
276
298
  // service failed AND it will not come back" is the one that needs a human now.
277
- const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath) : null;
299
+ const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath, target.port) : null;
278
300
  rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
279
301
  replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
280
302
  });
@@ -282,7 +304,7 @@ export async function runDaemonPhase(args, effects) {
282
304
  effects.out(recovery.recovered
283
305
  ? ok('your daemon is running again — nothing was committed, and the service is unchanged')
284
306
  : warn('and the daemon did NOT come back up — start it yourself before anything else: '
285
- + `ours daemon start --config ${configPath}`));
307
+ + `OURS_CONFIG=${configPath} ours-mcp start`));
286
308
  }
287
309
  throw error;
288
310
  }
@@ -302,10 +324,10 @@ export async function runDaemonPhase(args, effects) {
302
324
  * is REPORTED, never thrown: the caller is already carrying the real error, and
303
325
  * losing it to a second one would hide what actually went wrong.
304
326
  */
305
- async function recoverDaemon(args, effects, dir, configPath) {
327
+ async function recoverDaemon(args, effects, dir, configPath, port) {
306
328
  if (args.dryRun) return null;
307
329
  try {
308
- await effects.run('ours', ['daemon', 'start', '--config', configPath]);
330
+ await effects.run('ours-mcp', ['start'], { env: daemonEnv(dir, port) });
309
331
  return { recovered: true };
310
332
  } catch (recoveryError) {
311
333
  return { recovered: false, reason: reason(recoveryError) };
@@ -348,7 +370,7 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
348
370
  * the file — the owner's decision. `--force` is passed ONLY here, only for a unit
349
371
  * positively identified as ours-mcp's, and never for one we cannot identify.
350
372
  */
351
- export async function runServicePhase(args, effects, dir) {
373
+ export async function runServicePhase(args, effects, dir, port) {
352
374
  const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
353
375
  if (plan.action === 'refuse') {
354
376
  effects.out(warn(`ours: refusing to continue — ${plan.message}`));
@@ -357,6 +379,14 @@ export async function runServicePhase(args, effects, dir) {
357
379
  const adopting = plan.action === 'adopt';
358
380
  if (adopting) effects.out(info(plan.notice));
359
381
  const command = serviceInstallCommand({ stateDir: dir, adoptLegacyUnit: adopting });
382
+ // The unit's bytes BEFORE, so "did it change?" survives the loss of --json.
383
+ // `ours daemon install-service --json` used to answer that itself; ours-mcp's
384
+ // takes no flags and reports nothing machine-readable. Assuming `changed: true`
385
+ // would make every re-run claim it rewrote the unit, which turns the honest
386
+ // "everything already correct" line into one that never appears — a screen that
387
+ // is wrong in the reassuring direction. So the installer does the comparison it
388
+ // used to delegate: it already reads this file to classify it.
389
+ const unitBefore = plan.unitPath ? effects.readText(plan.unitPath) : null;
360
390
  let outcome;
361
391
  try {
362
392
  outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
@@ -368,11 +398,16 @@ export async function runServicePhase(args, effects, dir) {
368
398
  error.servicePlan = plan;
369
399
  throw error;
370
400
  }
371
- // Whether the unit actually changed is the CLI's answer, not ours: it does the
372
- // byte comparison and returns `changed` in its --json plan. Guessing here would
373
- // let a run that rewrote a unit report "nothing changed". Unreadable output is
374
- // treated as "changed", which is the safe direction for a summary line.
375
- const changed = readChanged(outcome.stdout);
401
+ // Ours now, by the same byte comparison the CLI used to do. A dry run changed
402
+ // nothing by definition; an unreadable file either side is treated as "changed",
403
+ // which is the safe direction for a summary line.
404
+ const changed = args.dryRun
405
+ ? false
406
+ : (() => {
407
+ const after = plan.unitPath ? effects.readText(plan.unitPath) : null;
408
+ if (unitBefore === null || after === null) return true;
409
+ return unitBefore !== after;
410
+ })();
376
411
  return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
377
412
  }
378
413
 
@@ -631,6 +666,19 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
631
666
  effects.out(info(wouldPrefix(`ours-mcp create-root "${name}"`)));
632
667
  return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
633
668
  }
669
+ // `ours-mcp create-root` — and it works BECAUSE the daemon is ours-mcp.
670
+ //
671
+ // It opens an MCP streamable-HTTP session to http://127.0.0.1:<port>/mcp. A
672
+ // daemon started by `ours daemon serve` does not mount that route (the SDK
673
+ // serves /mcp only when an `mcp` option is supplied, and @ours.network/cli calls
674
+ // startDaemon() with no arguments), which is why this exact command returned the
675
+ // daemon's own 404 body — "Not found" — on the first real install. An ours-mcp
676
+ // daemon supplies that option (packages/core/src/serve.ts), so the route exists
677
+ // and the command succeeds.
678
+ //
679
+ // Calling the SDK CLI's `ours identity create-root` instead would work too, over
680
+ // /api/v1/ — but it would mean installing and driving a second client purely to
681
+ // create an identity, on a daemon that already speaks the first one.
634
682
  try {
635
683
  const result = await effects.run('ours-mcp', ['create-root', name], { env });
636
684
  const existing = String(result?.stdout ?? '').match(/already exists \("([^"]+)"\)/);
@@ -648,11 +696,16 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
648
696
  }
649
697
  if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
650
698
  effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
651
- effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours-mcp create-root "${name}"'.`));
699
+ // The hint NAMES THIS DAEMON, and it has to keep doing that through the
700
+ // mechanism change. `ours daemon start` took --config; ours-mcp is selected
701
+ // by environment instead, so the pair travels as an env prefix rather than a
702
+ // flag. A retry command that omits it would start the DEFAULT daemon — which
703
+ // is how someone ends up with an identity on a daemon they did not choose.
704
+ effects.out(info(`Fix: run 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp start', then 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
652
705
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
653
706
  }
654
707
  effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
655
- effects.out(info(`Retry any time: 'ours-mcp create-root "${name}"'.`));
708
+ effects.out(info(`Retry any time: 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
656
709
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
657
710
  }
658
711
  }
@@ -1003,7 +1056,6 @@ export async function runInstall(argv, effects) {
1003
1056
  state: target.action === 'create' ? 'installed' : 'current',
1004
1057
  note: `port ${target.port}`,
1005
1058
  }];
1006
-
1007
1059
  const components = await runComponentPhase(args, effects, target);
1008
1060
  for (const component of COMPONENTS) {
1009
1061
  const state = components.installed.includes(component.key) ? 'installed'
package/lib/plan.mjs CHANGED
@@ -110,7 +110,38 @@ export function classifyUnit(text) {
110
110
  * without a question in front of it — it is what keeps --force from becoming a
111
111
  * default that spreads to the other cases.
112
112
  */
113
- export function planServiceInstall({ stateDir, home, readText }) {
113
+ export function planServiceInstall({ stateDir, home, readText, platform = 'linux' }) {
114
+ // THE BOOT SERVICE IS LINUX-ONLY, AND NOT BECAUSE THIS FILE SAYS SO.
115
+ //
116
+ // `ours daemon install-service` in @ours.network/cli builds its adapter with
117
+ // `createLinuxUserSystemdAdapter()` and no platform branch at all, and that
118
+ // factory's FIRST line is
119
+ // if (deps.platform !== 'linux') throw new Error('service management is not
120
+ // supported on <platform>; use an external launcher for `ours daemon serve`')
121
+ // — verified by reading the published 0.4.1 tarball, which contains zero
122
+ // occurrences of launchd, LaunchAgents or plist.
123
+ //
124
+ // So calling it on macOS does not degrade, it THROWS. Before this, a Mac user
125
+ // was told their platform was supported, watched the CLI install, the config
126
+ // write and the daemon start, and then got an exception and a rolled-back
127
+ // config. Skipping the step leaves them a working daemon and one true sentence
128
+ // instead — which is the whole of this change.
129
+ //
130
+ // A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
131
+ // package can install a launchd agent, and pretending otherwise by writing a
132
+ // plist ourselves would put a second service implementation in a second repo.
133
+ // THE macOS SKIP IS GONE, AND THAT IS A CONSEQUENCE OF THE DAEMON CHANGE.
134
+ //
135
+ // It existed because `ours daemon install-service` refuses on any non-linux
136
+ // platform — createLinuxUserSystemdAdapter() throws on its first line, and the
137
+ // whole @ours.network/cli package contains no launchd support. That was true and
138
+ // is no longer the relevant question: the unit is now installed by ours-mcp's
139
+ // own install-service, which handles systemd AND launchd
140
+ // (packages/core/src/cli.ts writes ~/Library/LaunchAgents and runs launchctl
141
+ // bootstrap). So macOS gets a real boot service rather than a named gap.
142
+ //
143
+ // A gap list that warns about something already fixed is as misleading as one
144
+ // that hides something broken, which is why the warning goes with the skip.
114
145
  const derived = unitPathForStateDir(stateDir, home);
115
146
  if (!derived.ok) {
116
147
  return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
@@ -180,12 +211,35 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
180
211
  // --json so the caller can read back whether the unit actually CHANGED. The
181
212
  // CLI owns that byte-comparison, and an installer that guessed at it would
182
213
  // report "nothing changed" on a run that rewrote a unit.
183
- const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
184
- // --force is reachable ONLY through this explicit argument, which the
185
- // orchestrator passes only after the user answered yes to legacyReplacePrompt.
186
- // It is never a default and never appears in a plan.
187
- if (adoptLegacyUnit) cmd.push('--force');
188
- return cmd;
214
+ // ours-mcp's install-service, NOT the SDK CLI's, and the difference is not
215
+ // cosmetic. `ours daemon install-service` writes a unit whose ExecStart runs
216
+ // `ours daemon serve` a daemon that does not mount /mcp — so the boot service
217
+ // would resurrect exactly the daemon the /mcp 404 came from. ours-mcp's writes
218
+ // ExecStart=<node> <ours-mcp> serve (packages/core/src/service-instance.ts:106),
219
+ // and it handles launchd as well as systemd.
220
+ //
221
+ // It takes no arguments: like `start`, it is selected by OURS_CONFIG /
222
+ // OURS_PORT / OURS_STATE_DIR, and it BAKES those resolved values into the unit.
223
+ // The caller passes the pair as an environment, which is why this returns a bare
224
+ // command and the orchestrator supplies daemonEnv.
225
+ //
226
+ // WHAT IS LOST, stated rather than discovered later. ours-mcp's install-service
227
+ // takes NO flags:
228
+ //
229
+ // · no --json, so the caller cannot read back whether the unit actually
230
+ // changed. `changed` is now assumed true, which is the safe direction for a
231
+ // summary line but is an assumption where it used to be an answer.
232
+ // · no --force, and no marker check either: it writes the unit file
233
+ // unconditionally. The SDK CLI refused to overwrite a unit it had not
234
+ // marked, and that refusal was the backstop behind classifyUnit. It is gone.
235
+ // classifyUnit's `foreign` refusal is now the ONLY thing standing between a
236
+ // stranger's unit file and an overwrite, so `adoptLegacyUnit` no longer
237
+ // changes the COMMAND — it records that the caller already decided, and the
238
+ // decision is enforced entirely by refusing to get here at all.
239
+ //
240
+ // That is a real reduction in defence in depth and it belongs in the PR, not in
241
+ // a comment nobody reads.
242
+ return ['ours-mcp', 'install-service'];
189
243
  }
190
244
 
191
245
  // -----------------------------------------------------------------------------
package/lib/target.mjs CHANGED
@@ -31,6 +31,33 @@ export const INSTALL_RESERVED_PORTS = [3051, 3052];
31
31
  // The CLI-owned PID record that proves a daemon belongs to a state directory
32
32
  // even when nothing is recorded in its config (spec §1).
33
33
  export const CLI_PID_RECORD = 'ours-cli-daemon.json';
34
+ // The SAME record, written by a different daemon. `ours daemon start` writes
35
+ // ours-cli-daemon.json; `ours-mcp start` writes daemon.pid (packages/core
36
+ // cli.ts PID_PATH). BOTH are read, and neither replaces the other: a machine
37
+ // installed yesterday has the first and a machine installed today has the
38
+ // second, and detection that goes blind on either one concludes "no daemon
39
+ // here" and creates a SECOND daemon on a state directory that already has one.
40
+ // Two writers on one state_data.bin is the corruption case this lookup exists
41
+ // to prevent, so the lookup has to know both spellings.
42
+ export const MCP_PID_RECORD = 'daemon.pid';
43
+ export const PID_RECORDS = [CLI_PID_RECORD, MCP_PID_RECORD];
44
+
45
+ /**
46
+ * The port a PID record names, whichever daemon wrote it.
47
+ *
48
+ * ours-cli-daemon.json is JSON with a `port`. daemon.pid is a bare pid — no
49
+ * port — so it proves a daemon EXISTS for this state directory without saying
50
+ * where. That distinction is the whole reason this returns both fields.
51
+ */
52
+ export function readPidRecords(target, readJson, readText) {
53
+ const record = readJson(join(target, CLI_PID_RECORD));
54
+ const port = record && typeof record.port === 'number' && Number.isFinite(record.port) ? record.port : null;
55
+ const rawPid = typeof readText === 'function' ? readText(join(target, MCP_PID_RECORD)) : null;
56
+ const pid = rawPid !== null && rawPid !== undefined && /^\s*\d+\s*$/.test(String(rawPid))
57
+ ? Number.parseInt(String(rawPid).trim(), 10)
58
+ : null;
59
+ return { port, pid };
60
+ }
34
61
  export const DAEMON_CONFIG = 'config.json';
35
62
 
36
63
  export class InstallUsageError extends Error {
@@ -192,7 +219,7 @@ export function classifyProbe(probe, targetStateDir) {
192
219
  * is still no I/O here, and `await` on a non-promise is the same value back, so
193
220
  * every existing fake keeps working unchanged.
194
221
  */
195
- export async function findDaemon({ stateDir, probe, readJson }) {
222
+ export async function findDaemon({ stateDir, probe, readJson, readText }) {
196
223
  const target = resolve(stateDir);
197
224
  const config = readJson(join(target, DAEMON_CONFIG));
198
225
  const recordedPort = config && typeof config.port === 'number' && Number.isFinite(config.port) ? config.port : null;
@@ -204,8 +231,8 @@ export async function findDaemon({ stateDir, probe, readJson }) {
204
231
  if (byConfig.kind === 'present') return { ...byConfig, port: first, via: 'config', config };
205
232
  if (byConfig.kind === 'foreign' && !guessed) return { ...byConfig, port: first, via: 'config', config };
206
233
 
207
- const record = readJson(join(target, CLI_PID_RECORD));
208
- const recorded = record && typeof record.port === 'number' && Number.isFinite(record.port) ? record.port : null;
234
+ const records = readPidRecords(target, readJson, readText);
235
+ const recorded = records.port;
209
236
  if (recorded !== null && recorded !== first) {
210
237
  const byRecord = classifyProbe(await probe(recorded), target);
211
238
  if (byRecord.kind === 'present') return { ...byRecord, port: recorded, via: 'pid-record', config };
@@ -229,6 +256,12 @@ export async function findDaemon({ stateDir, probe, readJson }) {
229
256
  // daemon owns THIS directory. A foreign answer on a port the directory
230
257
  // actually RECORDED still refuses, because there the operator's own file said
231
258
  // the daemon was there and something else is.
259
+ // A bare pid record with no port still says a daemon OWNS this directory. It
260
+ // cannot say where, so this is not "present" — but it is a reason to report a
261
+ // stale record rather than silently create a second daemon beside it.
262
+ if (records.pid !== null && byConfig.kind !== 'present') {
263
+ return { kind: 'absent', reason: 'a daemon pid record exists but nothing answers', stalePidRecord: first, port: first, via: 'config', config };
264
+ }
232
265
  if (byConfig.kind === 'foreign') {
233
266
  return {
234
267
  kind: 'absent',
@@ -265,9 +298,9 @@ export async function findDaemon({ stateDir, probe, readJson }) {
265
298
  * if it is occupied that is a refusal, not a reason to shift. Only a derived port
266
299
  * is searched, from 3050 upward, skipping the reserved defaults.
267
300
  */
268
- export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, isTaken }) {
301
+ export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, readText, isTaken }) {
269
302
  const target = resolve(stateDir);
270
- const found = await findDaemon({ stateDir: target, probe, readJson });
303
+ const found = await findDaemon({ stateDir: target, probe, readJson, readText });
271
304
 
272
305
  if (found.kind === 'foreign') {
273
306
  return {
package/lib/uninstall.mjs CHANGED
@@ -171,15 +171,24 @@ export function planComponentDetach(key, existing) {
171
171
  export function planDaemonRemoval({ stateDir, cliStartedIt }) {
172
172
  const dir = resolve(stateDir);
173
173
  const unit = unitNameForStateDir(dir);
174
+ // ours-mcp's own uninstall-service, for the same reason install-service is:
175
+ // the unit that exists was written by ours-mcp, and `ours daemon
176
+ // uninstall-service` refuses a unit it did not mark. It takes no flags and is
177
+ // selected by the environment, like every other ours-mcp lifecycle command.
178
+ //
179
+ // The macOS skip that briefly lived here is gone with the daemon change:
180
+ // ours-mcp's uninstall-service handles launchd as well as systemd, so there is
181
+ // no platform on which this declines to try.
182
+ const service = {
183
+ id: 'service',
184
+ unit: unit.ok ? unit.unit : null,
185
+ command: ['ours-mcp', 'uninstall-service'],
186
+ note: 'removes the unit ours-mcp installed',
187
+ };
174
188
  return [
175
- {
176
- id: 'service',
177
- unit: unit.ok ? unit.unit : null,
178
- command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
179
- note: 'refuses a unit not marked as CLI-managed',
180
- },
189
+ service,
181
190
  cliStartedIt
182
- ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--config', join(dir, 'config.json')] }
191
+ ? { id: 'stop', command: ['ours-mcp', 'stop'] }
183
192
  : { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
184
193
  ];
185
194
  }
@@ -474,7 +483,7 @@ export function selectHarnesses(plugins, chosen) {
474
483
  * The whole §8 order, refusing at step 1 rather than starting and stopping
475
484
  * half-way.
476
485
  */
477
- export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false }) {
486
+ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false, platform = 'linux' }) {
478
487
  const dir = resolve(stateDir);
479
488
  const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
480
489
  const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.11",
3
+ "version": "0.17.0-nightly.13",
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",