@ouro.bot/cli 0.1.0-alpha.193 → 0.1.0-alpha.194

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
@@ -2,8 +2,14 @@
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
4
  {
5
- "version": "0.1.0-alpha.193",
6
- "changes": [
5
+ "version": "0.1.0-alpha.194",
6
+ "changes": [
7
+ "fix(daemon): self-spawn restart \u2014 no longer relies on launchd KeepAlive for staged restarts",
8
+ "fix(daemon): error boundary with circuit breaker \u2014 uncaught exceptions logged and survived, exits only after 10+ in 60s",
9
+ "fix(daemon): EPIPE suppression in uncaughtException handler",
10
+ "fix(daemon): 5-second force-exit timeouts on all shutdown paths",
11
+ "feat: human-facing and agent-facing provider configs",
12
+ "fix(auth): always refresh codex OAuth token, responses API verification",
7
13
  "feat: Outlook visibility \u2014 orientation, obligations, changes, self-fix, memory decisions, route migration to /"
8
14
  ]
9
15
  },
@@ -235,7 +235,7 @@ function readCodexAccessToken(homeDir) {
235
235
  const raw = fs.readFileSync(authPath, "utf8");
236
236
  const parsed = JSON.parse(raw);
237
237
  const token = parsed?.tokens?.access_token;
238
- return typeof token === "string" ? token.trim() : "";
238
+ return typeof token === "string" ? token.trim() : /* v8 ignore next -- defensive: codex login always writes a string token @preserve */ "";
239
239
  }
240
240
  catch {
241
241
  return "";
@@ -302,25 +302,25 @@ async function collectRuntimeAuthCredentials(input, deps) {
302
302
  return { githubToken: token, baseUrl };
303
303
  }
304
304
  if (input.provider === "openai-codex") {
305
- let token = readCodexAccessToken(homeDir);
305
+ // Always run codex login when auth is explicitly requested — stale tokens
306
+ // are indistinguishable from valid ones without an API call, and the user
307
+ // is asking to re-authenticate.
308
+ (0, runtime_1.emitNervesEvent)({
309
+ component: "daemon",
310
+ event: "daemon.auth_codex_login_start",
311
+ message: "starting codex login for runtime auth",
312
+ meta: { agentName: input.agentName },
313
+ });
314
+ const result = spawnSync("codex", ["login"], { stdio: "inherit" });
315
+ if (result.error) {
316
+ throw new Error(`Failed to run 'codex login': ${result.error.message}`);
317
+ }
318
+ if (result.status !== 0) {
319
+ throw new Error(`'codex login' exited with status ${result.status}.`);
320
+ }
321
+ const token = readCodexAccessToken(homeDir);
306
322
  if (!token) {
307
- (0, runtime_1.emitNervesEvent)({
308
- component: "daemon",
309
- event: "daemon.auth_codex_login_start",
310
- message: "starting codex login for runtime auth",
311
- meta: { agentName: input.agentName },
312
- });
313
- const result = spawnSync("codex", ["login"], { stdio: "inherit" });
314
- if (result.error) {
315
- throw new Error(`Failed to run 'codex login': ${result.error.message}`);
316
- }
317
- if (result.status !== 0) {
318
- throw new Error(`'codex login' exited with status ${result.status}.`);
319
- }
320
- token = readCodexAccessToken(homeDir);
321
- if (!token) {
322
- throw new Error("Codex login completed but no token was found in ~/.codex/auth.json. Re-run `codex login` and try again.");
323
- }
323
+ throw new Error("Codex login completed but no token was found in ~/.codex/auth.json. Re-run `codex login` and try again.");
324
324
  }
325
325
  return { oauthAccessToken: token };
326
326
  }
@@ -170,11 +170,9 @@ void daemon.start().then(() => {
170
170
  scheduler.watchForChanges();
171
171
  habitSchedulers.push(scheduler);
172
172
  }
173
+ /* v8 ignore start -- startup failure + signal handlers: call process.exit, untestable in vitest @preserve */
173
174
  }).catch(async (err) => {
174
- /* v8 ignore stop */
175
- /* v8 ignore start — instanceof branch defensive; catch always receives Error in practice @preserve */
176
175
  const error = err instanceof Error ? err : new Error(String(err));
177
- /* v8 ignore stop */
178
176
  (0, daemon_tombstone_1.writeDaemonTombstone)("startupFailure", error);
179
177
  (0, runtime_1.emitNervesEvent)({
180
178
  level: "error",
@@ -183,6 +181,7 @@ void daemon.start().then(() => {
183
181
  message: "daemon entrypoint failed",
184
182
  meta: { error: error.message },
185
183
  });
184
+ setTimeout(() => process.exit(1), 5_000).unref();
186
185
  await daemon.stop();
187
186
  process.exit(1);
188
187
  });
@@ -191,6 +190,7 @@ process.on("SIGINT", () => {
191
190
  s.stopWatching();
192
191
  s.stop();
193
192
  }
193
+ setTimeout(() => process.exit(1), 5_000).unref();
194
194
  void daemon.stop().then(() => process.exit(0));
195
195
  });
196
196
  process.on("SIGTERM", () => {
@@ -198,29 +198,46 @@ process.on("SIGTERM", () => {
198
198
  s.stopWatching();
199
199
  s.stop();
200
200
  }
201
+ setTimeout(() => process.exit(1), 5_000).unref();
201
202
  void daemon.stop().then(() => process.exit(0));
202
203
  });
204
+ /* v8 ignore stop */
203
205
  // Suppress EPIPE on stdout/stderr — normal when detached daemon's parent exits
204
206
  /* v8 ignore start -- EPIPE suppression: only fires when parent process exits @preserve */
205
207
  process.stdout?.on?.("error", () => { });
206
208
  process.stderr?.on?.("error", () => { });
207
209
  /* v8 ignore stop */
208
210
  /* v8 ignore start -- global exception handlers: genuinely untestable in vitest; exercised by real daemon crashes @preserve */
211
+ let _uncaughtCount = 0;
212
+ const CIRCUIT_BREAKER_WINDOW_MS = 60_000;
213
+ const CIRCUIT_BREAKER_MAX = 10;
209
214
  process.on("uncaughtException", (error) => {
210
215
  // EPIPE is normal for detached daemon processes — parent closed the pipe
211
216
  if (error.code === "EPIPE")
212
217
  return;
218
+ _uncaughtCount++;
219
+ setTimeout(() => { _uncaughtCount--; }, CIRCUIT_BREAKER_WINDOW_MS).unref();
213
220
  (0, daemon_tombstone_1.writeDaemonTombstone)("uncaughtException", error);
214
221
  (0, runtime_1.emitNervesEvent)({
215
222
  level: "error",
216
223
  component: "daemon",
217
224
  event: "daemon.uncaught_exception",
218
- message: "uncaught exception in daemon process",
219
- meta: { error: error.message, stack: error.stack ?? null },
225
+ message: "uncaught exception in daemon process (continuing)",
226
+ meta: { error: error.message, stack: error.stack ?? null, uncaughtCount: _uncaughtCount },
220
227
  });
221
- // Graceful 5-second shutdown window
222
- setTimeout(() => process.exit(1), 5_000).unref();
223
- void daemon.stop().then(() => process.exit(1));
228
+ // Circuit breaker: if too many exceptions in a short window, the process
229
+ // is in a bad state — exit so launchd/self-spawn can restart fresh.
230
+ if (_uncaughtCount >= CIRCUIT_BREAKER_MAX) {
231
+ (0, runtime_1.emitNervesEvent)({
232
+ level: "error",
233
+ component: "daemon",
234
+ event: "daemon.circuit_breaker_exit",
235
+ message: `daemon exiting: ${_uncaughtCount} uncaught exceptions in ${CIRCUIT_BREAKER_WINDOW_MS / 1000}s`,
236
+ meta: { uncaughtCount: _uncaughtCount },
237
+ });
238
+ setTimeout(() => process.exit(1), 5_000).unref();
239
+ void daemon.stop().then(() => process.exit(1));
240
+ }
224
241
  });
225
242
  process.on("unhandledRejection", (reason) => {
226
243
  (0, runtime_1.emitNervesEvent)({
@@ -236,6 +236,7 @@ class OuroDaemon {
236
236
  // Start periodic update checker (polls npm registry every 30 minutes)
237
237
  // Skip in dev mode — dev builds should not auto-update from npm
238
238
  const bundlesRoot = this.bundlesRoot;
239
+ const daemonSocketPath = this.socketPath;
239
240
  if (this.mode === "dev") {
240
241
  (0, runtime_1.emitNervesEvent)({
241
242
  component: "daemon",
@@ -271,8 +272,19 @@ class OuroDaemon {
271
272
  }
272
273
  },
273
274
  gracefulShutdown: () => daemon.stop(),
275
+ spawnNewDaemon: (entryPath, sock) => {
276
+ const outFd = fs.openSync(os.devNull, "w");
277
+ const errFd = fs.openSync(os.devNull, "w");
278
+ const child = (0, child_process_1.spawn)(process.execPath, [entryPath, "--socket", sock], {
279
+ detached: true,
280
+ stdio: ["ignore", outFd, errFd],
281
+ });
282
+ child.unref();
283
+ return { pid: child.pid ?? null };
284
+ },
274
285
  nodePath: process.execPath,
275
286
  bundlesRoot,
287
+ socketPath: daemonSocketPath,
276
288
  });
277
289
  },
278
290
  /* v8 ignore stop */
@@ -90,25 +90,49 @@ async function performStagedRestart(version, deps) {
90
90
  });
91
91
  return { ok: false, error: `hook runner exited with code ${spawnResult.status}` };
92
92
  }
93
- // Step 4: Graceful shutdown (launchd will restart with new code)
93
+ // Step 4: Graceful shutdown then self-spawn new daemon
94
+ // We can't rely on launchd KeepAlive — the plist may not be loaded.
95
+ // Instead: shut down (releases socket), spawn new daemon from updated code.
94
96
  (0, runtime_1.emitNervesEvent)({
95
97
  component: "daemon",
96
98
  event: "daemon.staged_restart_hooks_passed",
97
- message: "hooks passed, shutting down for restart",
99
+ message: "hooks passed, shutting down and spawning new daemon",
98
100
  meta: { version },
99
101
  });
102
+ let shutdownError;
100
103
  try {
101
104
  await deps.gracefulShutdown();
102
105
  }
103
106
  catch (err) {
104
- const shutdownError = err instanceof Error ? err.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(err);
107
+ shutdownError = err instanceof Error ? err.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(err);
105
108
  (0, runtime_1.emitNervesEvent)({
106
109
  component: "daemon",
107
110
  event: "daemon.staged_restart_shutdown_error",
108
- message: "graceful shutdown encountered error",
111
+ message: "graceful shutdown encountered error (continuing with spawn)",
109
112
  meta: { version, error: shutdownError },
110
113
  });
111
- return { ok: true, shutdownError };
112
114
  }
113
- return { ok: true };
115
+ // Spawn new daemon from updated code path
116
+ const newEntry = path.join(newCodePath, "dist", "heart", "daemon", "daemon-entry.js");
117
+ const socketArg = deps.socketPath ?? "/tmp/ouroboros-daemon.sock";
118
+ try {
119
+ const { pid } = deps.spawnNewDaemon(newEntry, socketArg);
120
+ (0, runtime_1.emitNervesEvent)({
121
+ component: "daemon",
122
+ event: "daemon.staged_restart_spawned",
123
+ message: "new daemon spawned successfully",
124
+ meta: { version, pid, entry: newEntry },
125
+ });
126
+ }
127
+ catch (err) {
128
+ const spawnError = err instanceof Error ? err.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(err);
129
+ (0, runtime_1.emitNervesEvent)({
130
+ component: "daemon",
131
+ event: "daemon.staged_restart_respawn_failed",
132
+ message: "failed to spawn new daemon after shutdown",
133
+ meta: { version, error: spawnError, entry: newEntry },
134
+ });
135
+ return { ok: false, error: `shutdown succeeded but failed to spawn new daemon: ${spawnError}`, shutdownError };
136
+ }
137
+ return { ok: true, shutdownError };
114
138
  }
@@ -123,8 +123,14 @@ async function pingProvider(provider, config) {
123
123
  const client = runtime.client;
124
124
  await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal: controller.signal, headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" } });
125
125
  }
126
+ else if (provider === "openai-codex") {
127
+ // Codex uses the Responses API (chatgpt.com/backend-api/codex/responses),
128
+ // not the Chat Completions API. Ping via responses endpoint.
129
+ const client = runtime.client;
130
+ await client.responses.create({ model: runtime.model, input: "ping", store: false }, { signal: controller.signal });
131
+ }
126
132
  else {
127
- // OpenAI-compatible providers (azure, codex, minimax, github-copilot)
133
+ // OpenAI-compatible providers (azure, minimax, github-copilot)
128
134
  const client = runtime.client;
129
135
  await client.chat.completions.create({ model: runtime.model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }, { signal: controller.signal });
130
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.193",
3
+ "version": "0.1.0-alpha.194",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",