arisa 2.0.0 → 2.0.1

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 CHANGED
@@ -13,15 +13,41 @@ Arisa can execute actions with operational control over the system where it runs
13
13
  ## Commands
14
14
 
15
15
  Requires [Bun](https://bun.sh).
16
+ For Bun global installs, use your user environment (do not use `sudo`).
17
+ If needed, configure Bun's user-local install directory:
18
+
19
+ ```bash
20
+ export BUN_INSTALL="$HOME/.bun"
21
+ export PATH="$BUN_INSTALL/bin:$PATH"
22
+ ```
23
+
24
+ ```bash
25
+ bun install -g arisa # Global install from package registry (recommended)
26
+ npm install -g arisa # Alternative global install via npm (may require sudo)
27
+ ```
28
+
29
+ ```bash
30
+ arisa start # Start as service (enables autostart with systemd --user)
31
+ arisa stop # Stop service
32
+ arisa status # Service status
33
+ arisa restart # Restart service
34
+ arisa daemon # Foreground daemon mode (manual/dev)
35
+ arisa core # Foreground core-only mode
36
+ arisa dev # Foreground core watch mode
37
+ ```
16
38
 
17
39
  ```bash
18
40
  bun install # Install dependencies
19
41
  bun run daemon # Start everything (Daemon spawns Core with --watch)
20
42
  bun run dev # Start Core only with hot-reload (for development)
21
- bun install -g arisa # Global install from package registry
22
43
  npm install -g . # Global install via Node/npm
23
44
  bun add -g . # Global install via Bun
24
- arisa # Start daemon from global install
45
+ ```
46
+
47
+ On Linux with `systemd --user`, `arisa start` enables auto-start on reboot. To keep it running even without an active login session:
48
+
49
+ ```bash
50
+ sudo loginctl enable-linger "$USER"
25
51
  ```
26
52
 
27
53
  ## Architecture: Daemon + Core
package/bin/arisa.js CHANGED
@@ -1,10 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawnSync } = require("node:child_process");
4
- const { readFileSync } = require("node:fs");
3
+ const { spawn, spawnSync } = require("node:child_process");
4
+ const {
5
+ closeSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } = require("node:fs");
13
+ const { homedir, platform } = require("node:os");
5
14
  const { join, resolve } = require("node:path");
6
15
 
7
16
  const pkgRoot = resolve(__dirname, "..");
17
+ const daemonEntry = join(pkgRoot, "src", "daemon", "index.ts");
18
+ const coreEntry = join(pkgRoot, "src", "core", "index.ts");
19
+ const homeDir = homedir();
20
+ const arisaDir = join(homeDir, ".arisa");
21
+ const runDir = join(arisaDir, "run");
22
+ const logsDir = join(arisaDir, "logs");
23
+ const pidFile = join(runDir, "arisa.pid");
24
+ const fallbackLogFile = join(logsDir, "service.log");
25
+ const systemdServiceName = "arisa.service";
26
+ const systemdUserDir = join(homeDir, ".config", "systemd", "user");
27
+ const systemdUserUnitPath = join(systemdUserDir, systemdServiceName);
28
+
8
29
  const args = process.argv.slice(2);
9
30
  const command = (args[0] || "start").toLowerCase();
10
31
  const rest = args.slice(1);
@@ -14,9 +35,15 @@ function printHelp() {
14
35
  `Arisa CLI
15
36
 
16
37
  Usage:
17
- arisa Start daemon (default)
18
- arisa start Start daemon
19
- arisa daemon Start daemon
38
+ arisa Start service (default)
39
+ arisa start Start service and enable restart-on-boot
40
+ arisa stop Stop service
41
+ arisa status Show service status
42
+ arisa restart Restart service
43
+ arisa daemon Start daemon in foreground
44
+ arisa run Start daemon in foreground
45
+ arisa start --foreground
46
+ Start daemon in foreground (legacy behavior)
20
47
  arisa core Start core only
21
48
  arisa dev Start core in watch mode
22
49
  arisa version Print version
@@ -31,6 +58,344 @@ function printVersion() {
31
58
  process.stdout.write(`${pkg.version}\n`);
32
59
  }
33
60
 
61
+ function commandExists(binary) {
62
+ const probe = spawnSync("sh", ["-lc", `command -v ${binary} >/dev/null 2>&1`], {
63
+ stdio: "ignore",
64
+ });
65
+ return probe.status === 0;
66
+ }
67
+
68
+ function runCommand(executable, commandArgs, options = {}) {
69
+ return spawnSync(executable, commandArgs, {
70
+ encoding: "utf8",
71
+ ...options,
72
+ });
73
+ }
74
+
75
+ function resolveBunExecutable() {
76
+ if (process.env.BUN_BIN && process.env.BUN_BIN.trim()) {
77
+ return process.env.BUN_BIN.trim();
78
+ }
79
+
80
+ const bunInstall = process.env.BUN_INSTALL || join(homeDir, ".bun");
81
+ const bunFromInstall = join(bunInstall, "bin", "bun");
82
+ if (existsSync(bunFromInstall)) {
83
+ return bunFromInstall;
84
+ }
85
+
86
+ return "bun";
87
+ }
88
+
89
+ function runWithBun(bunArgs, options = {}) {
90
+ const bunExecutable = resolveBunExecutable();
91
+ const child = runCommand(bunExecutable, bunArgs, {
92
+ stdio: "inherit",
93
+ cwd: process.cwd(),
94
+ env: {
95
+ ...process.env,
96
+ ARISA_PROJECT_DIR: process.env.ARISA_PROJECT_DIR || pkgRoot,
97
+ },
98
+ shell: process.platform === "win32",
99
+ ...options,
100
+ });
101
+
102
+ if (child.error) {
103
+ if (child.error.code === "ENOENT") {
104
+ process.stderr.write(
105
+ "Arisa requires Bun to run. Install it from https://bun.sh/ and retry.\n"
106
+ );
107
+ process.exit(1);
108
+ }
109
+ process.stderr.write(`${String(child.error)}\n`);
110
+ process.exit(1);
111
+ }
112
+
113
+ return child;
114
+ }
115
+
116
+ function ensureRuntimeDirs() {
117
+ mkdirSync(runDir, { recursive: true });
118
+ mkdirSync(logsDir, { recursive: true });
119
+ }
120
+
121
+ function readPid() {
122
+ if (!existsSync(pidFile)) return null;
123
+
124
+ try {
125
+ const raw = readFileSync(pidFile, "utf8").trim();
126
+ const parsed = Number.parseInt(raw, 10);
127
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function isPidRunning(pid) {
134
+ try {
135
+ process.kill(pid, 0);
136
+ return true;
137
+ } catch (error) {
138
+ return error && error.code === "EPERM";
139
+ }
140
+ }
141
+
142
+ function removePidFile() {
143
+ if (!existsSync(pidFile)) return;
144
+ try {
145
+ unlinkSync(pidFile);
146
+ } catch {
147
+ // Best effort cleanup.
148
+ }
149
+ }
150
+
151
+ function cleanupStalePidFile() {
152
+ const pid = readPid();
153
+ if (!pid) {
154
+ removePidFile();
155
+ return null;
156
+ }
157
+
158
+ if (!isPidRunning(pid)) {
159
+ removePidFile();
160
+ return null;
161
+ }
162
+
163
+ return pid;
164
+ }
165
+
166
+ function startDetachedFallback() {
167
+ ensureRuntimeDirs();
168
+ const runningPid = cleanupStalePidFile();
169
+ if (runningPid) {
170
+ process.stdout.write(
171
+ `Arisa is already running in fallback mode (PID ${runningPid}).\n`
172
+ );
173
+ return 0;
174
+ }
175
+
176
+ const bunExecutable = resolveBunExecutable();
177
+ const logFd = openSync(fallbackLogFile, "a");
178
+ const child = spawn(bunExecutable, [daemonEntry], {
179
+ detached: true,
180
+ stdio: ["ignore", logFd, logFd],
181
+ cwd: pkgRoot,
182
+ env: {
183
+ ...process.env,
184
+ ARISA_PROJECT_DIR: process.env.ARISA_PROJECT_DIR || pkgRoot,
185
+ },
186
+ shell: process.platform === "win32",
187
+ });
188
+
189
+ closeSync(logFd);
190
+ if (!child.pid) {
191
+ process.stderr.write(
192
+ "Failed to start Arisa in fallback mode. Ensure Bun is installed and in PATH.\n"
193
+ );
194
+ return 1;
195
+ }
196
+
197
+ child.unref();
198
+
199
+ writeFileSync(pidFile, `${child.pid}\n`, "utf8");
200
+ process.stdout.write(
201
+ `Arisa started in fallback mode (PID ${child.pid}). Logs: ${fallbackLogFile}\n`
202
+ );
203
+ process.stdout.write(
204
+ "Autostart on reboot requires systemd user services.\n"
205
+ );
206
+ return 0;
207
+ }
208
+
209
+ function stopDetachedFallback() {
210
+ const runningPid = cleanupStalePidFile();
211
+ if (!runningPid) {
212
+ process.stdout.write("Arisa is not running (fallback mode).\n");
213
+ return 0;
214
+ }
215
+
216
+ try {
217
+ process.kill(runningPid, "SIGTERM");
218
+ removePidFile();
219
+ process.stdout.write(`Sent SIGTERM to Arisa (PID ${runningPid}).\n`);
220
+ return 0;
221
+ } catch (error) {
222
+ process.stderr.write(
223
+ `Failed to stop Arisa PID ${runningPid}: ${error.message}\n`
224
+ );
225
+ return 1;
226
+ }
227
+ }
228
+
229
+ function statusDetachedFallback() {
230
+ const runningPid = cleanupStalePidFile();
231
+ if (!runningPid) {
232
+ process.stdout.write("Arisa is not running (fallback mode).\n");
233
+ return 1;
234
+ }
235
+
236
+ process.stdout.write(`Arisa is running in fallback mode (PID ${runningPid}).\n`);
237
+ process.stdout.write(`Logs: ${fallbackLogFile}\n`);
238
+ return 0;
239
+ }
240
+
241
+ function canUseSystemdUser() {
242
+ if (platform() !== "linux") return false;
243
+ if (!commandExists("systemctl")) return false;
244
+
245
+ const probe = runCommand("systemctl", ["--user", "show-environment"], {
246
+ stdio: "pipe",
247
+ });
248
+
249
+ return probe.status === 0;
250
+ }
251
+
252
+ function writeSystemdUserUnit() {
253
+ mkdirSync(systemdUserDir, { recursive: true });
254
+
255
+ const bunInstall = process.env.BUN_INSTALL || join(homeDir, ".bun");
256
+ const pathValue = process.env.PATH || `${join(bunInstall, "bin")}:/usr/local/bin:/usr/bin:/bin`;
257
+ const bunExecutable = resolveBunExecutable();
258
+
259
+ const unit = `[Unit]
260
+ Description=Arisa Daemon Service
261
+ After=network-online.target
262
+ Wants=network-online.target
263
+
264
+ [Service]
265
+ Type=simple
266
+ WorkingDirectory=${pkgRoot}
267
+ ExecStart=${bunExecutable} ${daemonEntry}
268
+ Restart=always
269
+ RestartSec=3
270
+ Environment=ARISA_PROJECT_DIR=${pkgRoot}
271
+ Environment=BUN_INSTALL=${bunInstall}
272
+ Environment=PATH=${pathValue}
273
+
274
+ [Install]
275
+ WantedBy=default.target
276
+ `;
277
+
278
+ writeFileSync(systemdUserUnitPath, unit, "utf8");
279
+ }
280
+
281
+ function runSystemd(commandArgs) {
282
+ const child = runCommand("systemctl", ["--user", ...commandArgs], {
283
+ stdio: "pipe",
284
+ });
285
+
286
+ if (child.status !== 0) {
287
+ const stderr = child.stderr || "Unknown systemd error";
288
+ process.stderr.write(stderr.endsWith("\n") ? stderr : `${stderr}\n`);
289
+ return { ok: false, status: child.status ?? 1 };
290
+ }
291
+
292
+ return { ok: true, status: 0, stdout: child.stdout || "" };
293
+ }
294
+
295
+ function startSystemdService() {
296
+ writeSystemdUserUnit();
297
+
298
+ const reload = runSystemd(["daemon-reload"]);
299
+ if (!reload.ok) return reload.status;
300
+
301
+ const start = runSystemd(["enable", "--now", systemdServiceName]);
302
+ if (!start.ok) return start.status;
303
+
304
+ process.stdout.write(
305
+ "Arisa service started and enabled (systemd --user).\n"
306
+ );
307
+ process.stdout.write(
308
+ "To keep it running after reboot without login, run: sudo loginctl enable-linger $USER\n"
309
+ );
310
+ return 0;
311
+ }
312
+
313
+ function stopSystemdService() {
314
+ const stop = runSystemd(["stop", systemdServiceName]);
315
+ if (!stop.ok) return stop.status;
316
+
317
+ process.stdout.write("Arisa service stopped (systemd --user).\n");
318
+ return 0;
319
+ }
320
+
321
+ function restartSystemdService() {
322
+ const restart = runSystemd(["restart", systemdServiceName]);
323
+ if (!restart.ok) return restart.status;
324
+
325
+ process.stdout.write("Arisa service restarted (systemd --user).\n");
326
+ return 0;
327
+ }
328
+
329
+ function statusSystemdService() {
330
+ const activeResult = runCommand(
331
+ "systemctl",
332
+ ["--user", "is-active", systemdServiceName],
333
+ { stdio: "pipe" }
334
+ );
335
+ const enabledResult = runCommand(
336
+ "systemctl",
337
+ ["--user", "is-enabled", systemdServiceName],
338
+ { stdio: "pipe" }
339
+ );
340
+
341
+ const active = activeResult.status === 0;
342
+ const enabled = enabledResult.status === 0;
343
+
344
+ if (active) {
345
+ process.stdout.write("Arisa service status: active (systemd --user).\n");
346
+ } else {
347
+ process.stdout.write("Arisa service status: inactive (systemd --user).\n");
348
+ }
349
+
350
+ if (enabled) {
351
+ process.stdout.write("Autostart: enabled\n");
352
+ return active ? 0 : 1;
353
+ }
354
+
355
+ process.stdout.write("Autostart: disabled\n");
356
+ return active ? 0 : 1;
357
+ }
358
+
359
+ function restartDetachedFallback() {
360
+ const stopCode = stopDetachedFallback();
361
+ if (stopCode !== 0) return stopCode;
362
+ return startDetachedFallback();
363
+ }
364
+
365
+ function startService() {
366
+ if (rest.includes("--foreground")) {
367
+ const foregroundArgs = rest.filter((arg) => arg !== "--foreground");
368
+ const child = runWithBun([daemonEntry, ...foregroundArgs]);
369
+ return child.status === null ? 1 : child.status;
370
+ }
371
+
372
+ if (canUseSystemdUser()) {
373
+ return startSystemdService();
374
+ }
375
+ return startDetachedFallback();
376
+ }
377
+
378
+ function stopService() {
379
+ if (canUseSystemdUser()) {
380
+ return stopSystemdService();
381
+ }
382
+ return stopDetachedFallback();
383
+ }
384
+
385
+ function statusService() {
386
+ if (canUseSystemdUser()) {
387
+ return statusSystemdService();
388
+ }
389
+ return statusDetachedFallback();
390
+ }
391
+
392
+ function restartService() {
393
+ if (canUseSystemdUser()) {
394
+ return restartSystemdService();
395
+ }
396
+ return restartDetachedFallback();
397
+ }
398
+
34
399
  if (command === "help" || command === "--help" || command === "-h") {
35
400
  printHelp();
36
401
  process.exit(0);
@@ -41,45 +406,36 @@ if (command === "version" || command === "--version" || command === "-v") {
41
406
  process.exit(0);
42
407
  }
43
408
 
44
- const env = {
45
- ...process.env,
46
- };
47
-
48
- let bunArgs;
49
409
  switch (command) {
50
410
  case "start":
51
- case "daemon":
52
- bunArgs = [join(pkgRoot, "src", "daemon", "index.ts"), ...rest];
411
+ process.exit(startService());
53
412
  break;
54
- case "core":
55
- bunArgs = [join(pkgRoot, "src", "core", "index.ts"), ...rest];
413
+ case "stop":
414
+ process.exit(stopService());
56
415
  break;
57
- case "dev":
58
- bunArgs = ["--watch", join(pkgRoot, "src", "core", "index.ts"), ...rest];
416
+ case "status":
417
+ process.exit(statusService());
418
+ break;
419
+ case "restart":
420
+ process.exit(restartService());
59
421
  break;
422
+ case "daemon":
423
+ case "run": {
424
+ const child = runWithBun([daemonEntry, ...rest]);
425
+ process.exit(child.status === null ? 1 : child.status);
426
+ }
427
+ case "core":
428
+ {
429
+ const child = runWithBun([coreEntry, ...rest]);
430
+ process.exit(child.status === null ? 1 : child.status);
431
+ }
432
+ case "dev":
433
+ {
434
+ const child = runWithBun(["--watch", coreEntry, ...rest]);
435
+ process.exit(child.status === null ? 1 : child.status);
436
+ }
60
437
  default:
61
438
  process.stderr.write(`Unknown command: ${command}\n\n`);
62
439
  printHelp();
63
440
  process.exit(1);
64
441
  }
65
-
66
- const bunExecutable = process.env.BUN_BIN || "bun";
67
- const child = spawnSync(bunExecutable, bunArgs, {
68
- stdio: "inherit",
69
- cwd: process.cwd(),
70
- env,
71
- shell: process.platform === "win32",
72
- });
73
-
74
- if (child.error) {
75
- if (child.error.code === "ENOENT") {
76
- process.stderr.write(
77
- "Arisa requires Bun to run. Install it from https://bun.sh/ and retry.\n"
78
- );
79
- process.exit(1);
80
- }
81
- process.stderr.write(`${String(child.error)}\n`);
82
- process.exit(1);
83
- }
84
-
85
- process.exit(child.status === null ? 1 : child.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Arisa - dynamic agent runtime with daemon/core architecture that evolves through user interaction",
5
5
  "preferGlobal": true,
6
6
  "bin": {
package/src/core/index.ts CHANGED
@@ -18,7 +18,12 @@ await config.secrets.initialize();
18
18
  import { createLogger } from "../shared/logger";
19
19
  import { serveWithRetry, claimProcess } from "../shared/ports";
20
20
  import type { IncomingMessage, CoreResponse, ScheduledTask } from "../shared/types";
21
- import { processWithClaude, processWithCodex, isClaudeRateLimitResponse } from "./processor";
21
+ import {
22
+ processWithClaude,
23
+ processWithCodex,
24
+ isClaudeRateLimitResponse,
25
+ isCodexAuthRequiredResponse,
26
+ } from "./processor";
22
27
  import { transcribeAudio, describeImage, generateSpeech, isMediaConfigured, isSpeechConfigured } from "./media";
23
28
  import { detectFiles } from "./file-detector";
24
29
 
@@ -363,12 +368,16 @@ ${messageText}`;
363
368
  if (isClaudeRateLimitResponse(agentResponse) && canFallback) {
364
369
  log.warn("Claude credits exhausted, falling back to Codex");
365
370
  const codexResponse = await processWithCodex(enrichedMessage);
366
- agentResponse = `Claude is out of credits right now, so I switched this reply to Codex.\n---CHUNK---\n${codexResponse}`;
367
- historyResponse = codexResponse;
368
- usedBackend = "codex";
369
- // Persist the switch so subsequent messages don't keep re-injecting
370
- // cross-backend context while Claude has no credits.
371
- backendState.set(msg.chatId, "codex");
371
+ if (isCodexAuthRequiredResponse(codexResponse)) {
372
+ agentResponse = `${agentResponse}\n---CHUNK---\n${codexResponse}`;
373
+ } else {
374
+ agentResponse = `Claude is out of credits right now, so I switched this reply to Codex.\n---CHUNK---\n${codexResponse}`;
375
+ historyResponse = codexResponse;
376
+ usedBackend = "codex";
377
+ // Persist the switch so subsequent messages don't keep re-injecting
378
+ // cross-backend context while Claude has no credits.
379
+ backendState.set(msg.chatId, "codex");
380
+ }
372
381
  }
373
382
  } catch (error) {
374
383
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -23,6 +23,12 @@ const log = createLogger("core");
23
23
  const ACTIVITY_LOG = join(config.logsDir, "activity.log");
24
24
  const PROMPT_PREVIEW_MAX = 220;
25
25
  export const CLAUDE_RATE_LIMIT_MESSAGE = "Claude is out of credits right now. Please try again in a few minutes.";
26
+ export const CODEX_AUTH_REQUIRED_MESSAGE = [
27
+ "Codex is not authenticated on this server.",
28
+ "Check the server terminal and run:",
29
+ "<code>codex login --device-auth</code>",
30
+ "Then try again.",
31
+ ].join("\n");
26
32
 
27
33
  function logActivity(backend: string, model: string | null, durationMs: number, status: string) {
28
34
  try {
@@ -230,8 +236,12 @@ export async function processWithCodex(message: string): Promise<string> {
230
236
  const duration = Date.now() - start;
231
237
 
232
238
  if (exitCode !== 0) {
239
+ const combined = `${stdout}\n${stderr}`;
233
240
  log.error(`Codex exited with code ${exitCode}: ${stderr.substring(0, 200)}`);
234
241
  logActivity("codex", null, duration, `error:${exitCode}`);
242
+ if (isCodexAuthError(combined)) {
243
+ return CODEX_AUTH_REQUIRED_MESSAGE;
244
+ }
235
245
  return "Error processing with Codex. Please try again.";
236
246
  }
237
247
 
@@ -256,6 +266,10 @@ export function isClaudeRateLimitResponse(text: string): boolean {
256
266
  return text.trim() === CLAUDE_RATE_LIMIT_MESSAGE;
257
267
  }
258
268
 
269
+ export function isCodexAuthRequiredResponse(text: string): boolean {
270
+ return text.trim() === CODEX_AUTH_REQUIRED_MESSAGE;
271
+ }
272
+
259
273
  function summarizeError(raw: string): string {
260
274
  const clean = raw.replace(/\s+/g, " ").trim();
261
275
  if (!clean) return "process ended without details.";
@@ -266,3 +280,11 @@ function summarizeError(raw: string): string {
266
280
  function isRateLimit(output: string): boolean {
267
281
  return /you'?ve hit your limit|rate limit|quota|credits.*(exceeded|exhausted)/i.test(output);
268
282
  }
283
+
284
+ function isCodexAuthError(output: string): boolean {
285
+ return (
286
+ /missing bearer authentication in header/i.test(output)
287
+ || (/401\s+Unauthorized/i.test(output) && /bearer/i.test(output))
288
+ || (/failed to refresh available models/i.test(output) && /unauthorized/i.test(output))
289
+ );
290
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @module daemon/codex-login
3
+ * @role Trigger Codex device auth flow from Daemon when auth errors are detected.
4
+ * @responsibilities
5
+ * - Detect codex auth-required signals in Core responses
6
+ * - Run `codex login --device-auth` in background from daemon process
7
+ * - Avoid duplicate runs with in-progress lock + cooldown
8
+ * @effects Spawns codex CLI process, writes to daemon logs/terminal
9
+ */
10
+
11
+ import { config } from "../shared/config";
12
+ import { createLogger } from "../shared/logger";
13
+
14
+ const log = createLogger("daemon");
15
+
16
+ const AUTH_HINT_PATTERNS = [
17
+ /codex login --device-auth/i,
18
+ /codex is not authenticated on this server/i,
19
+ /missing bearer authentication in header/i,
20
+ ];
21
+
22
+ const RETRY_COOLDOWN_MS = 30_000;
23
+
24
+ let loginInProgress = false;
25
+ let lastLoginAttemptAt = 0;
26
+
27
+ function needsCodexLogin(text: string): boolean {
28
+ return AUTH_HINT_PATTERNS.some((pattern) => pattern.test(text));
29
+ }
30
+
31
+ export function maybeStartCodexDeviceAuth(rawCoreText: string): void {
32
+ if (!rawCoreText || !needsCodexLogin(rawCoreText)) return;
33
+
34
+ if (loginInProgress) {
35
+ log.info("Codex device auth already in progress; skipping duplicate trigger");
36
+ return;
37
+ }
38
+
39
+ const now = Date.now();
40
+ if (now - lastLoginAttemptAt < RETRY_COOLDOWN_MS) {
41
+ log.info("Codex device auth trigger ignored (cooldown active)");
42
+ return;
43
+ }
44
+
45
+ lastLoginAttemptAt = now;
46
+ loginInProgress = true;
47
+ void runCodexDeviceAuth().finally(() => {
48
+ loginInProgress = false;
49
+ });
50
+ }
51
+
52
+ async function runCodexDeviceAuth(): Promise<void> {
53
+ log.warn("Codex auth error detected. Running: codex login --device-auth");
54
+
55
+ let proc: ReturnType<typeof Bun.spawn>;
56
+ try {
57
+ proc = Bun.spawn(["codex", "login", "--device-auth"], {
58
+ cwd: config.projectDir,
59
+ stdin: "inherit",
60
+ stdout: "inherit",
61
+ stderr: "inherit",
62
+ env: { ...process.env },
63
+ });
64
+ } catch (error) {
65
+ log.error(`Failed to start codex login: ${error}`);
66
+ return;
67
+ }
68
+
69
+ const exitCode = await proc.exited;
70
+ if (exitCode === 0) {
71
+ log.info("Codex device auth finished successfully");
72
+ } else {
73
+ log.error(`Codex device auth finished with exit code ${exitCode}`);
74
+ }
75
+ }
@@ -28,6 +28,7 @@ const { TelegramChannel } = await import("./channels/telegram");
28
28
  const { sendToCore } = await import("./bridge");
29
29
  const { startCore, stopCore, setLifecycleNotify } = await import("./lifecycle");
30
30
  const { setAutoFixNotify } = await import("./autofix");
31
+ const { maybeStartCodexDeviceAuth } = await import("./codex-login");
31
32
  const { chunkMessage, markdownToTelegramHtml } = await import("../core/format");
32
33
  const { saveMessageRecord } = await import("../shared/db");
33
34
 
@@ -77,6 +78,7 @@ telegram.onMessage(async (msg) => {
77
78
  clearInterval(typingInterval);
78
79
 
79
80
  const raw = response.text || "";
81
+ maybeStartCodexDeviceAuth(raw);
80
82
  const messageParts = raw.split(/\n---CHUNK---\n/g);
81
83
  let sentText = false;
82
84