@ouro.bot/cli 0.1.0-alpha.319 → 0.1.0-alpha.320

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/changelog.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.320",
6
+ "changes": [
7
+ "fix(daemon): make `ouro up` wait for real startup stability before reporting success. The CLI now stays attached through ordered startup phases, requires sustained socket liveness plus fresh current-boot health evidence, retries once when startup loses the socket, and surfaces recent daemon log context on failure instead of claiming the daemon started.",
8
+ "fix(daemon): harden socket ownership and recoverable bootstrap behavior. An older daemon shutdown no longer unlinks a newer daemon's rebound socket path, and recoverable habit bootstrap failures now degrade with actionable `habit_setup_error` and `bootstrap_degraded` guidance instead of taking the whole daemon down.",
9
+ "fix(testing): declare @testing-library/dom in outlook-ui devDependencies so coverage-gate installs match the package's actual React Testing Library requirements."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.319",
6
14
  "changes": [
@@ -64,6 +64,8 @@ const agent_discovery_1 = require("./agent-discovery");
64
64
  const bundle_manifest_1 = require("../../mind/bundle-manifest");
65
65
  const ouro_bot_global_installer_1 = require("../versioning/ouro-bot-global-installer");
66
66
  const logs_prune_1 = require("./logs-prune");
67
+ const daemon_health_1 = require("./daemon-health");
68
+ const log_tailer_1 = require("./log-tailer");
67
69
  const launchd_1 = require("./launchd");
68
70
  const socket_client_1 = require("./socket-client");
69
71
  const session_activity_1 = require("../session-activity");
@@ -121,6 +123,25 @@ function defaultCleanupStaleSocket(socketPath) {
121
123
  fs.unlinkSync(socketPath);
122
124
  }
123
125
  }
126
+ function defaultReadHealthUpdatedAt(healthPath) {
127
+ try {
128
+ return fs.statSync(healthPath).mtimeMs;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ function defaultReadRecentDaemonLogLines(lines = 10) {
135
+ const files = (0, log_tailer_1.discoverLogFiles)({});
136
+ const recentLines = [];
137
+ for (const file of files) {
138
+ recentLines.push(...(0, log_tailer_1.readLastLines)(file, lines, fs.readFileSync));
139
+ }
140
+ return recentLines.slice(-lines).map((line) => (0, log_tailer_1.formatLogLine)(line));
141
+ }
142
+ function defaultSleep(ms) {
143
+ return new Promise((resolve) => setTimeout(resolve, ms));
144
+ }
124
145
  function defaultFallbackPendingMessage(command) {
125
146
  const inboxDir = path.join((0, identity_1.getAgentBundlesRoot)(), `${command.to}.ouro`, "inbox");
126
147
  const pendingPath = path.join(inboxDir, "pending.jsonl");
@@ -466,6 +487,16 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
466
487
  checkSocketAlive: socket_client_1.checkDaemonSocketAlive,
467
488
  cleanupStaleSocket: defaultCleanupStaleSocket,
468
489
  fallbackPendingMessage: defaultFallbackPendingMessage,
490
+ healthFilePath: (0, daemon_health_1.getDefaultHealthPath)(),
491
+ readHealthState: daemon_health_1.readHealth,
492
+ readHealthUpdatedAt: defaultReadHealthUpdatedAt,
493
+ readRecentDaemonLogLines: defaultReadRecentDaemonLogLines,
494
+ sleep: defaultSleep,
495
+ now: () => Date.now(),
496
+ startupPollIntervalMs: 250,
497
+ startupStabilityWindowMs: 1_500,
498
+ startupTimeoutMs: 10_000,
499
+ startupRetryLimit: 1,
469
500
  listDiscoveredAgents: defaultListDiscoveredAgents,
470
501
  runHatchFlow: hatch_flow_1.runHatchFlow,
471
502
  promptInput: defaultPromptInput,
@@ -77,6 +77,11 @@ const startup_tui_1 = require("./startup-tui");
77
77
  const stale_bundle_prune_1 = require("./stale-bundle-prune");
78
78
  const up_progress_1 = require("./up-progress");
79
79
  // ── ensureDaemonRunning ──
80
+ const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 10_000;
81
+ const DEFAULT_DAEMON_STARTUP_POLL_INTERVAL_MS = 500;
82
+ const DEFAULT_DAEMON_STARTUP_STABILITY_WINDOW_MS = 1_500;
83
+ const DEFAULT_DAEMON_STARTUP_RETRY_LIMIT = 1;
84
+ const DEFAULT_DAEMON_STARTUP_LOG_LINES = 10;
80
85
  async function ensureDaemonRunning(deps) {
81
86
  const alive = await deps.checkSocketAlive(deps.socketPath);
82
87
  if (alive) {
@@ -111,66 +116,192 @@ async function ensureDaemonRunning(deps) {
111
116
  checkSocketAlive: deps.checkSocketAlive,
112
117
  });
113
118
  }
114
- deps.cleanupStaleSocket(deps.socketPath);
115
- const started = await deps.startDaemonProcess(deps.socketPath);
116
- const pid = started.pid ?? "unknown";
117
- // Poll daemon status with real-time TUI progress until all agents
118
- // are either stable (running 5s+) or definitively failed (crashed).
119
- const stability = await (0, startup_tui_1.pollDaemonStartup)({
120
- sendCommand: deps.sendCommand,
121
- socketPath: deps.socketPath,
122
- daemonPid: started.pid ?? null,
123
- /* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
124
- writeRaw: (text) => process.stdout.write(text),
125
- /* v8 ignore next -- thin wrapper: real Date.now() injected for testability @preserve */
126
- now: () => Date.now(),
127
- /* v8 ignore next -- thin wrapper: real setTimeout injected for testability @preserve */
128
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
129
- /* v8 ignore start -- daemon log tail + pid check: reads real filesystem, tested via deployment @preserve */
130
- readLatestDaemonEvent: () => {
131
- try {
132
- // The daemon writes structured events to daemon.ndjson in the first
133
- // agent bundle's state/daemon/logs/ directory. Read the last line to
134
- // surface what it's currently doing (e.g., "starting auto-start agents").
135
- const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
136
- if (!fs.existsSync(bundlesRoot))
137
- return null;
138
- const agents = fs.readdirSync(bundlesRoot).filter((d) => d.endsWith(".ouro"));
139
- for (const agent of agents) {
140
- const logPath = path.join(bundlesRoot, agent, "state", "daemon", "logs", "daemon.ndjson");
141
- if (!fs.existsSync(logPath))
142
- continue;
143
- const stat = fs.statSync(logPath);
144
- if (stat.size === 0)
145
- continue;
146
- // Only read logs from the last 30 seconds (daemon just started)
147
- const mtime = stat.mtimeMs;
148
- if (Date.now() - mtime > 30_000)
149
- continue;
150
- const buf = Buffer.alloc(4096);
151
- const fd = fs.openSync(logPath, "r");
119
+ const retryLimit = deps.startupRetryLimit ?? DEFAULT_DAEMON_STARTUP_RETRY_LIMIT;
120
+ let lastFailure = {
121
+ reason: "daemon failed before the startup monitor recorded a failure",
122
+ retryable: false,
123
+ };
124
+ let lastPid = null;
125
+ const readLatestDaemonStartupEvent = () => {
126
+ try {
127
+ // The daemon writes structured events to daemon.ndjson in the first
128
+ // agent bundle's state/daemon/logs/ directory. Read the last line to
129
+ // surface what it's currently doing (e.g., "starting auto-start agents").
130
+ const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
131
+ if (!fs.existsSync(bundlesRoot))
132
+ return null;
133
+ const agents = fs.readdirSync(bundlesRoot).filter((d) => d.endsWith(".ouro"));
134
+ for (const agent of agents) {
135
+ const logPath = path.join(bundlesRoot, agent, "state", "daemon", "logs", "daemon.ndjson");
136
+ if (!fs.existsSync(logPath))
137
+ continue;
138
+ const stat = fs.statSync(logPath);
139
+ if (stat.size === 0)
140
+ continue;
141
+ // Only read logs from the last 30 seconds (daemon just started)
142
+ const mtime = stat.mtimeMs;
143
+ if (Date.now() - mtime > 30_000)
144
+ continue;
145
+ const buf = Buffer.alloc(4096);
146
+ const fd = fs.openSync(logPath, "r");
147
+ let bytesRead = 0;
148
+ try {
152
149
  const readFrom = Math.max(0, stat.size - 4096);
153
- fs.readSync(fd, buf, 0, 4096, readFrom);
150
+ bytesRead = fs.readSync(fd, buf, 0, 4096, readFrom);
151
+ }
152
+ finally {
154
153
  fs.closeSync(fd);
155
- const lines = buf.toString("utf-8").trim().split("\n").filter(Boolean);
156
- const last = lines[lines.length - 1];
157
- if (!last)
158
- continue;
159
- const parsed = JSON.parse(last);
160
- return parsed.message ?? null;
161
154
  }
155
+ const lines = buf.subarray(0, bytesRead).toString("utf-8").trim().split("\n").filter(Boolean);
156
+ const last = lines[lines.length - 1];
157
+ if (!last)
158
+ continue;
159
+ const parsed = JSON.parse(last);
160
+ return parsed.message ?? null;
162
161
  }
163
- catch { /* best effort */ }
164
- return null;
165
- },
166
- /* v8 ignore stop */
167
- });
162
+ }
163
+ catch {
164
+ // Best effort only.
165
+ }
166
+ return null;
167
+ };
168
+ for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
169
+ deps.reportDaemonStartupPhase?.("starting daemon...");
170
+ deps.reportDaemonStartupPhase?.("waiting for daemon socket...");
171
+ deps.cleanupStaleSocket(deps.socketPath);
172
+ const bootStartedAtMs = (deps.now ?? Date.now)();
173
+ const started = await deps.startDaemonProcess(deps.socketPath);
174
+ lastPid = started.pid ?? null;
175
+ const startupFailure = await waitForDaemonStartup(deps, {
176
+ bootStartedAtMs,
177
+ pid: lastPid,
178
+ });
179
+ if (!startupFailure) {
180
+ const stability = await (0, startup_tui_1.pollDaemonStartup)({
181
+ sendCommand: deps.sendCommand,
182
+ socketPath: deps.socketPath,
183
+ daemonPid: lastPid,
184
+ /* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
185
+ writeRaw: (text) => process.stdout.write(text),
186
+ /* v8 ignore next -- thin wrapper: real Date.now() injected for testability @preserve */
187
+ now: () => Date.now(),
188
+ /* v8 ignore next -- thin wrapper: real setTimeout injected for testability @preserve */
189
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
190
+ /* v8 ignore start -- daemon log tail + pid check: reads real filesystem, tested via deployment @preserve */
191
+ readLatestDaemonEvent: readLatestDaemonStartupEvent,
192
+ /* v8 ignore stop */
193
+ });
194
+ return {
195
+ alreadyRunning: false,
196
+ message: `daemon started (pid ${lastPid ?? "unknown"})`,
197
+ stability,
198
+ };
199
+ }
200
+ lastFailure = startupFailure;
201
+ if (!startupFailure.retryable || attempt >= retryLimit) {
202
+ break;
203
+ }
204
+ deps.reportDaemonStartupPhase?.("daemon startup lost stability; cleaning up and retrying once...");
205
+ }
168
206
  return {
169
207
  alreadyRunning: false,
170
- message: `daemon started (pid ${pid})`,
171
- stability,
208
+ message: formatDaemonStartupFailureMessage(lastPid, lastFailure, deps),
172
209
  };
173
210
  }
211
+ function hasStartupHealthMonitor(deps) {
212
+ return !!deps.healthFilePath && !!deps.readHealthState && !!deps.readHealthUpdatedAt;
213
+ }
214
+ function hasFreshCurrentBootHealthSignal(deps, bootStartedAtMs, pid) {
215
+ const healthState = deps.readHealthState(deps.healthFilePath);
216
+ if (!healthState)
217
+ return false;
218
+ const healthUpdatedAt = deps.readHealthUpdatedAt(deps.healthFilePath);
219
+ if (healthUpdatedAt === null || healthUpdatedAt < bootStartedAtMs) {
220
+ return false;
221
+ }
222
+ const healthStartedAtMs = Date.parse(healthState.startedAt);
223
+ if (!Number.isFinite(healthStartedAtMs) || healthStartedAtMs < bootStartedAtMs) {
224
+ return false;
225
+ }
226
+ if (pid !== null && healthState.pid !== pid) {
227
+ return false;
228
+ }
229
+ return true;
230
+ }
231
+ function formatDaemonStartupFailureMessage(pid, failure, deps) {
232
+ const lines = [
233
+ `daemon spawned (pid ${pid ?? "unknown"}) but failed to stabilize: ${failure.reason}`,
234
+ ];
235
+ const recentLogLines = deps.readRecentDaemonLogLines?.(DEFAULT_DAEMON_STARTUP_LOG_LINES) ?? [];
236
+ if (recentLogLines.length > 0) {
237
+ lines.push("recent daemon logs:");
238
+ lines.push(...recentLogLines.map((line) => ` ${line}`));
239
+ }
240
+ lines.push("fix hint for daemon: check daemon logs or run `ouro doctor`");
241
+ return lines.join("\n");
242
+ }
243
+ async function waitForDaemonStartup(deps, options) {
244
+ const now = deps.now ?? Date.now;
245
+ const sleep = deps.sleep ?? defaultSleep;
246
+ const timeoutMs = deps.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS;
247
+ const pollIntervalMs = deps.startupPollIntervalMs ?? DEFAULT_DAEMON_STARTUP_POLL_INTERVAL_MS;
248
+ const stabilityWindowMs = deps.startupStabilityWindowMs ?? DEFAULT_DAEMON_STARTUP_STABILITY_WINDOW_MS;
249
+ const deadline = options.bootStartedAtMs + timeoutMs;
250
+ const useHealthMonitor = hasStartupHealthMonitor(deps);
251
+ let stableSinceMs = null;
252
+ let sawSocket = false;
253
+ if (!useHealthMonitor) {
254
+ const verified = await verifyDaemonAlive(deps.checkSocketAlive, deps.socketPath, timeoutMs, pollIntervalMs, sleep, now);
255
+ return verified
256
+ ? null
257
+ : {
258
+ reason: `daemon failed to respond within ${Math.ceil(timeoutMs / 1000)}s`,
259
+ retryable: false,
260
+ };
261
+ }
262
+ while (now() < deadline) {
263
+ await sleep(pollIntervalMs);
264
+ const aliveNow = await deps.checkSocketAlive(deps.socketPath);
265
+ if (!aliveNow) {
266
+ if (sawSocket) {
267
+ return {
268
+ reason: "daemon socket disappeared during startup",
269
+ retryable: true,
270
+ };
271
+ }
272
+ continue;
273
+ }
274
+ if (!sawSocket) {
275
+ sawSocket = true;
276
+ stableSinceMs = now();
277
+ deps.reportDaemonStartupPhase?.("verifying daemon health...");
278
+ }
279
+ if (!hasFreshCurrentBootHealthSignal(deps, options.bootStartedAtMs, options.pid)) {
280
+ continue;
281
+ }
282
+ if (stableSinceMs !== null && now() - stableSinceMs >= stabilityWindowMs) {
283
+ return null;
284
+ }
285
+ }
286
+ return {
287
+ reason: sawSocket
288
+ ? "daemon did not publish fresh health for the current boot attempt"
289
+ : `daemon failed to respond within ${Math.ceil(timeoutMs / 1000)}s`,
290
+ retryable: sawSocket,
291
+ };
292
+ }
293
+ async function verifyDaemonAlive(checkSocketAlive, socketPath, maxWaitMs = 10_000, pollIntervalMs = 500, sleep = defaultSleep, now = Date.now) {
294
+ const deadline = now() + maxWaitMs;
295
+ while (now() < deadline) {
296
+ await sleep(pollIntervalMs);
297
+ if (await checkSocketAlive(socketPath))
298
+ return true;
299
+ }
300
+ return false;
301
+ }
302
+ function defaultSleep(ms) {
303
+ return new Promise((resolve) => setTimeout(resolve, ms));
304
+ }
174
305
  // ── GitHub Copilot model helpers ──
175
306
  async function listGithubCopilotModels(baseUrl, token, fetchImpl = fetch) {
176
307
  const url = `${baseUrl.replace(/\/+$/, "")}/models`;
@@ -933,7 +1064,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
933
1064
  progress.completePhase("bundle cleanup", `pruned ${prunedBundles.length} stale bundle${prunedBundles.length === 1 ? "" : "s"}`);
934
1065
  }
935
1066
  progress.startPhase("starting daemon");
936
- const daemonResult = await ensureDaemonRunning(deps);
1067
+ const daemonResult = await ensureDaemonRunning({
1068
+ ...deps,
1069
+ reportDaemonStartupPhase: (label) => {
1070
+ ;
1071
+ progress.announceStep?.(label);
1072
+ },
1073
+ });
937
1074
  progress.end();
938
1075
  deps.writeStdout(daemonResult.message);
939
1076
  // Interactive repair for degraded agents (Unit 5) — skipped by --no-repair (Unit 6)
@@ -169,19 +169,62 @@ const daemon = new daemon_1.OuroDaemon({
169
169
  router,
170
170
  mode,
171
171
  });
172
+ const daemonStartedAt = new Date().toISOString();
173
+ const degradedComponents = [];
174
+ function buildDaemonHealthState() {
175
+ return {
176
+ status: degradedComponents.length > 0 ? "degraded" : "ok",
177
+ mode,
178
+ pid: process.pid,
179
+ startedAt: daemonStartedAt,
180
+ uptimeSeconds: Math.floor(process.uptime()),
181
+ safeMode: null,
182
+ degraded: degradedComponents.map((entry) => ({ ...entry })),
183
+ agents: {},
184
+ habits: {},
185
+ };
186
+ }
187
+ function recordRecoverableBootstrapFailure(options) {
188
+ const errorMessage = options.error instanceof Error ? options.error.message : String(options.error);
189
+ const existing = degradedComponents.find((entry) => entry.component === options.component);
190
+ const reason = `${errorMessage}. ${options.guidance}`;
191
+ if (existing) {
192
+ existing.reason = reason;
193
+ }
194
+ else {
195
+ degradedComponents.push({
196
+ component: options.component,
197
+ reason,
198
+ since: new Date().toISOString(),
199
+ });
200
+ }
201
+ (0, runtime_1.emitNervesEvent)({
202
+ level: "warn",
203
+ component: "daemon",
204
+ event: "daemon.bootstrap_degraded",
205
+ message: "recoverable daemon bootstrap failure; daemon remains available in degraded mode",
206
+ meta: {
207
+ agent: options.agent,
208
+ component: options.component,
209
+ habitsDir: options.habitsDir,
210
+ error: errorMessage,
211
+ guidance: options.guidance,
212
+ },
213
+ });
214
+ }
215
+ function emitHabitSetupError(agent, error) {
216
+ const normalized = error instanceof Error ? error : new Error(String(error));
217
+ (0, runtime_1.emitNervesEvent)({
218
+ level: "error",
219
+ component: "daemon",
220
+ event: "daemon.habit_setup_error",
221
+ message: `habit setup failed for agent ${agent}`,
222
+ meta: { agent, error: normalized.message },
223
+ });
224
+ }
172
225
  /* v8 ignore start — daemon health writer wiring, tested via daemon-health.test.ts @preserve */
173
226
  const healthWriter = new daemon_health_1.DaemonHealthWriter((0, daemon_health_1.getDefaultHealthPath)());
174
- const healthSink = (0, daemon_health_1.createHealthNervesSink)(healthWriter, () => ({
175
- status: "ok",
176
- mode,
177
- pid: process.pid,
178
- startedAt: new Date().toISOString(),
179
- uptimeSeconds: Math.floor(process.uptime()),
180
- safeMode: null,
181
- degraded: [],
182
- agents: {},
183
- habits: {},
184
- }));
227
+ const healthSink = (0, daemon_health_1.createHealthNervesSink)(healthWriter, buildDaemonHealthState);
185
228
  (0, index_1.registerGlobalLogSink)(healthSink);
186
229
  /* v8 ignore stop */
187
230
  const habitSchedulers = [];
@@ -191,9 +234,10 @@ void daemon.start().then(() => {
191
234
  const ouroPath = (0, os_cron_deps_1.resolveOuroBinaryPath)();
192
235
  const osCronDeps = (0, os_cron_deps_1.createRealOsCronDeps)();
193
236
  for (const agent of managedAgents) {
237
+ const bundleRoot = path.join(bundlesRoot, `${agent}.ouro`);
238
+ const habitsDir = path.join(bundleRoot, "habits");
239
+ const degradedComponent = `habits:${agent}`;
194
240
  try {
195
- const bundleRoot = path.join(bundlesRoot, `${agent}.ouro`);
196
- const habitsDir = path.join(bundleRoot, "habits");
197
241
  // Migrate old tasks/habits/ to habits/ at bundle root
198
242
  (0, habit_migration_1.migrateHabitsFromTaskSystem)(bundleRoot);
199
243
  const osCronManager = new os_cron_1.LaunchdCronManager(osCronDeps);
@@ -214,19 +258,39 @@ void daemon.start().then(() => {
214
258
  watch: (dir, cb) => fs.watch(dir, cb),
215
259
  },
216
260
  });
217
- scheduler.start();
218
- scheduler.startPeriodicReconciliation();
219
- scheduler.watchForChanges();
220
- habitSchedulers.push(scheduler);
261
+ try {
262
+ scheduler.start();
263
+ scheduler.startPeriodicReconciliation();
264
+ scheduler.watchForChanges();
265
+ habitSchedulers.push(scheduler);
266
+ }
267
+ catch (error) {
268
+ try {
269
+ scheduler.stopWatching();
270
+ scheduler.stop();
271
+ }
272
+ catch {
273
+ // Cleanup is best-effort for partially initialized schedulers.
274
+ }
275
+ emitHabitSetupError(agent, error);
276
+ recordRecoverableBootstrapFailure({
277
+ agent,
278
+ component: degradedComponent,
279
+ habitsDir,
280
+ error,
281
+ guidance: `fix ${agent} habits or cron setup and rerun ouro up to restore habit automation`,
282
+ });
283
+ }
221
284
  }
222
285
  catch (err) {
223
286
  const error = err instanceof Error ? err : new Error(String(err));
224
- (0, runtime_1.emitNervesEvent)({
225
- level: "error",
226
- component: "daemon",
227
- event: "daemon.habit_setup_error",
228
- message: `habit setup failed for agent ${agent}`,
229
- meta: { agent, error: error.message },
287
+ emitHabitSetupError(agent, error);
288
+ recordRecoverableBootstrapFailure({
289
+ agent,
290
+ component: degradedComponent,
291
+ habitsDir,
292
+ error,
293
+ guidance: `fix ${agent} habits or cron setup and rerun ouro up to restore habit automation`,
230
294
  });
231
295
  }
232
296
  }
@@ -78,6 +78,7 @@ exports.HEALTH_TRACKED_EVENTS = new Set([
78
78
  "daemon.agent_restart_exhausted",
79
79
  "daemon.agent_permanent_failure",
80
80
  "daemon.agent_cooldown_recovery",
81
+ "daemon.bootstrap_degraded",
81
82
  "daemon.safe_mode_entered",
82
83
  "daemon.habit_scheduler_start",
83
84
  ]);
@@ -271,6 +271,24 @@ function writePidfile(extraPids = []) {
271
271
  }
272
272
  catch { /* best effort */ }
273
273
  }
274
+ function readSocketIdentity(socketPath) {
275
+ try {
276
+ const stats = fs.lstatSync(socketPath);
277
+ return {
278
+ dev: stats.dev,
279
+ ino: stats.ino,
280
+ ctimeMs: stats.ctimeMs,
281
+ };
282
+ }
283
+ catch {
284
+ return null;
285
+ }
286
+ }
287
+ function sameSocketIdentity(left, right) {
288
+ if (!left || !right)
289
+ return false;
290
+ return left.dev === right.dev && left.ino === right.ino && left.ctimeMs === right.ctimeMs;
291
+ }
274
292
  function buildWorkerRows(snapshots) {
275
293
  return snapshots.map((snapshot) => ({
276
294
  agent: snapshot.name,
@@ -355,6 +373,7 @@ class OuroDaemon {
355
373
  mode;
356
374
  server = null;
357
375
  outlookServer = null;
376
+ socketIdentity = null;
358
377
  outlookServerFactory;
359
378
  constructor(options) {
360
379
  this.socketPath = options.socketPath;
@@ -625,6 +644,7 @@ class OuroDaemon {
625
644
  server.listen(this.socketPath, () => {
626
645
  // Replace the one-time error listener with a persistent one after successful listen
627
646
  server.removeAllListeners("error");
647
+ this.socketIdentity = readSocketIdentity(this.socketPath);
628
648
  /* v8 ignore start — server error after listen requires real socket race condition @preserve */
629
649
  server.on("error", (err) => {
630
650
  (0, runtime_1.emitNervesEvent)({
@@ -827,9 +847,31 @@ class OuroDaemon {
827
847
  await this.outlookServer.stop();
828
848
  this.outlookServer = null;
829
849
  }
830
- if (fs.existsSync(this.socketPath)) {
850
+ const socketPathExists = fs.existsSync(this.socketPath);
851
+ const currentSocketIdentity = socketPathExists ? readSocketIdentity(this.socketPath) : null;
852
+ if (sameSocketIdentity(this.socketIdentity, currentSocketIdentity)) {
831
853
  fs.unlinkSync(this.socketPath);
832
854
  }
855
+ else if (socketPathExists) {
856
+ const expectedSocketIdentity = { dev: null, ino: null, ctimeMs: null, ...this.socketIdentity };
857
+ const actualSocketIdentity = { dev: null, ino: null, ctimeMs: null, ...currentSocketIdentity };
858
+ (0, runtime_1.emitNervesEvent)({
859
+ level: "warn",
860
+ component: "daemon",
861
+ event: "daemon.socket_cleanup_skipped",
862
+ message: "skipped daemon socket cleanup because the socket path no longer belongs to this daemon",
863
+ meta: {
864
+ socketPath: this.socketPath,
865
+ expectedDev: expectedSocketIdentity.dev,
866
+ expectedIno: expectedSocketIdentity.ino,
867
+ expectedCtimeMs: expectedSocketIdentity.ctimeMs,
868
+ actualDev: actualSocketIdentity.dev,
869
+ actualIno: actualSocketIdentity.ino,
870
+ actualCtimeMs: actualSocketIdentity.ctimeMs,
871
+ },
872
+ });
873
+ }
874
+ this.socketIdentity = null;
833
875
  }
834
876
  async handleRawPayload(raw) {
835
877
  try {
@@ -43,6 +43,15 @@ class UpProgress {
43
43
  }
44
44
  this.currentPhase = { label, startedAt: Date.now() };
45
45
  }
46
+ /**
47
+ * Emit a one-line status breadcrumb in non-TTY mode without affecting the
48
+ * accumulated checklist state. Used for daemon startup sub-steps.
49
+ */
50
+ announceStep(label) {
51
+ if (this.isTTY)
52
+ return;
53
+ this.write(label);
54
+ }
46
55
  /**
47
56
  * Mark the current phase as done. In non-TTY mode, immediately writes
48
57
  * a static line. Emits a nerves event for observability.