@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.
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
}
|