arisa 2.0.0 → 2.0.2

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,34 @@ 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:
16
18
 
17
19
  ```bash
18
- bun install # Install dependencies
19
- bun run daemon # Start everything (Daemon spawns Core with --watch)
20
- bun run dev # Start Core only with hot-reload (for development)
21
- bun install -g arisa # Global install from package registry
22
- npm install -g . # Global install via Node/npm
23
- bun add -g . # Global install via Bun
24
- arisa # Start daemon from global install
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
+ ```
27
+
28
+ ```bash
29
+ arisa # Foreground daemon mode (Ctrl+C to stop)
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
+ ```
38
+
39
+
40
+ On Linux with `systemd --user`, `arisa start` enables auto-start on reboot. To keep it running even without an active login session:
41
+
42
+ ```bash
43
+ sudo loginctl enable-linger "$USER"
25
44
  ```
26
45
 
27
46
  ## Architecture: Daemon + Core
@@ -69,12 +88,12 @@ src/
69
88
  │ ├── media.ts # Voice transcription (Whisper), image analysis (Vision), speech synthesis (ElevenLabs)
70
89
  │ ├── scheduler.ts # Cron + one-time tasks with croner, persists via deepbase
71
90
  │ ├── format.ts # Telegram chunking (4096 char limit)
72
- │ ├── file-detector.ts # Detect file paths in responses for auto-sending
91
+ │ ├── file-detector.ts # Detect file paths in responses for auto-sending
73
92
  │ └── context.ts # Manage -c flag and reset_flag
74
93
 
75
94
  └── shared/
76
95
  ├── types.ts # All shared interfaces
77
- ├── config.ts # Env vars, ports, paths
96
+ ├── config.ts # Env vars, ports, paths
78
97
  ├── logger.ts # Logger → .arisa/logs/
79
98
  └── db.ts # Unified persistence layer (deepbase)
80
99
  ```
package/bin/arisa.js CHANGED
@@ -1,22 +1,51 @@
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
- const command = (args[0] || "start").toLowerCase();
10
- const rest = args.slice(1);
30
+ const inputCommand = (args[0] || "").toLowerCase();
31
+ const command = inputCommand || "daemon";
32
+ const rest = inputCommand ? args.slice(1) : args;
33
+ const isDefaultInvocation = inputCommand === "";
11
34
 
12
35
  function printHelp() {
13
36
  process.stdout.write(
14
37
  `Arisa CLI
15
38
 
16
39
  Usage:
17
- arisa Start daemon (default)
18
- arisa start Start daemon
19
- arisa daemon Start daemon
40
+ arisa Start daemon in foreground (default)
41
+ arisa start Start service and enable restart-on-boot
42
+ arisa stop Stop service
43
+ arisa status Show service status
44
+ arisa restart Restart service
45
+ arisa daemon Start daemon in foreground
46
+ arisa run Start daemon in foreground
47
+ arisa start --foreground
48
+ Start daemon in foreground (legacy behavior)
20
49
  arisa core Start core only
21
50
  arisa dev Start core in watch mode
22
51
  arisa version Print version
@@ -31,6 +60,349 @@ function printVersion() {
31
60
  process.stdout.write(`${pkg.version}\n`);
32
61
  }
33
62
 
63
+ function commandExists(binary) {
64
+ const probe = spawnSync("sh", ["-lc", `command -v ${binary} >/dev/null 2>&1`], {
65
+ stdio: "ignore",
66
+ });
67
+ return probe.status === 0;
68
+ }
69
+
70
+ function runCommand(executable, commandArgs, options = {}) {
71
+ return spawnSync(executable, commandArgs, {
72
+ encoding: "utf8",
73
+ ...options,
74
+ });
75
+ }
76
+
77
+ function resolveBunExecutable() {
78
+ if (process.env.BUN_BIN && process.env.BUN_BIN.trim()) {
79
+ return process.env.BUN_BIN.trim();
80
+ }
81
+
82
+ const bunInstall = process.env.BUN_INSTALL || join(homeDir, ".bun");
83
+ const bunFromInstall = join(bunInstall, "bin", "bun");
84
+ if (existsSync(bunFromInstall)) {
85
+ return bunFromInstall;
86
+ }
87
+
88
+ return "bun";
89
+ }
90
+
91
+ function runWithBun(bunArgs, options = {}) {
92
+ const bunExecutable = resolveBunExecutable();
93
+ const child = runCommand(bunExecutable, bunArgs, {
94
+ stdio: "inherit",
95
+ cwd: process.cwd(),
96
+ env: {
97
+ ...process.env,
98
+ ARISA_PROJECT_DIR: process.env.ARISA_PROJECT_DIR || pkgRoot,
99
+ },
100
+ shell: process.platform === "win32",
101
+ ...options,
102
+ });
103
+
104
+ if (child.error) {
105
+ if (child.error.code === "ENOENT") {
106
+ process.stderr.write(
107
+ "Arisa requires Bun to run. Install it from https://bun.sh/ and retry.\n"
108
+ );
109
+ process.exit(1);
110
+ }
111
+ process.stderr.write(`${String(child.error)}\n`);
112
+ process.exit(1);
113
+ }
114
+
115
+ return child;
116
+ }
117
+
118
+ function ensureRuntimeDirs() {
119
+ mkdirSync(runDir, { recursive: true });
120
+ mkdirSync(logsDir, { recursive: true });
121
+ }
122
+
123
+ function readPid() {
124
+ if (!existsSync(pidFile)) return null;
125
+
126
+ try {
127
+ const raw = readFileSync(pidFile, "utf8").trim();
128
+ const parsed = Number.parseInt(raw, 10);
129
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ function isPidRunning(pid) {
136
+ try {
137
+ process.kill(pid, 0);
138
+ return true;
139
+ } catch (error) {
140
+ return error && error.code === "EPERM";
141
+ }
142
+ }
143
+
144
+ function removePidFile() {
145
+ if (!existsSync(pidFile)) return;
146
+ try {
147
+ unlinkSync(pidFile);
148
+ } catch {
149
+ // Best effort cleanup.
150
+ }
151
+ }
152
+
153
+ function cleanupStalePidFile() {
154
+ const pid = readPid();
155
+ if (!pid) {
156
+ removePidFile();
157
+ return null;
158
+ }
159
+
160
+ if (!isPidRunning(pid)) {
161
+ removePidFile();
162
+ return null;
163
+ }
164
+
165
+ return pid;
166
+ }
167
+
168
+ function startDetachedFallback() {
169
+ ensureRuntimeDirs();
170
+ const runningPid = cleanupStalePidFile();
171
+ if (runningPid) {
172
+ process.stdout.write(
173
+ `Arisa is already running in fallback mode (PID ${runningPid}).\n`
174
+ );
175
+ return 0;
176
+ }
177
+
178
+ const bunExecutable = resolveBunExecutable();
179
+ const logFd = openSync(fallbackLogFile, "a");
180
+ const child = spawn(bunExecutable, [daemonEntry], {
181
+ detached: true,
182
+ stdio: ["ignore", logFd, logFd],
183
+ cwd: pkgRoot,
184
+ env: {
185
+ ...process.env,
186
+ ARISA_PROJECT_DIR: process.env.ARISA_PROJECT_DIR || pkgRoot,
187
+ },
188
+ shell: process.platform === "win32",
189
+ });
190
+
191
+ closeSync(logFd);
192
+ if (!child.pid) {
193
+ process.stderr.write(
194
+ "Failed to start Arisa in fallback mode. Ensure Bun is installed and in PATH.\n"
195
+ );
196
+ return 1;
197
+ }
198
+
199
+ child.unref();
200
+
201
+ writeFileSync(pidFile, `${child.pid}\n`, "utf8");
202
+ process.stdout.write(
203
+ `Arisa started in fallback mode (PID ${child.pid}). Logs: ${fallbackLogFile}\n`
204
+ );
205
+ process.stdout.write(
206
+ "Autostart on reboot requires systemd user services.\n"
207
+ );
208
+ return 0;
209
+ }
210
+
211
+ function stopDetachedFallback() {
212
+ const runningPid = cleanupStalePidFile();
213
+ if (!runningPid) {
214
+ process.stdout.write("Arisa is not running (fallback mode).\n");
215
+ return 0;
216
+ }
217
+
218
+ try {
219
+ process.kill(runningPid, "SIGTERM");
220
+ removePidFile();
221
+ process.stdout.write(`Sent SIGTERM to Arisa (PID ${runningPid}).\n`);
222
+ return 0;
223
+ } catch (error) {
224
+ process.stderr.write(
225
+ `Failed to stop Arisa PID ${runningPid}: ${error.message}\n`
226
+ );
227
+ return 1;
228
+ }
229
+ }
230
+
231
+ function statusDetachedFallback() {
232
+ const runningPid = cleanupStalePidFile();
233
+ if (!runningPid) {
234
+ process.stdout.write("Arisa is not running (fallback mode).\n");
235
+ return 1;
236
+ }
237
+
238
+ process.stdout.write(`Arisa is running in fallback mode (PID ${runningPid}).\n`);
239
+ process.stdout.write(`Logs: ${fallbackLogFile}\n`);
240
+ return 0;
241
+ }
242
+
243
+ function canUseSystemdUser() {
244
+ if (platform() !== "linux") return false;
245
+ if (!commandExists("systemctl")) return false;
246
+
247
+ const probe = runCommand("systemctl", ["--user", "show-environment"], {
248
+ stdio: "pipe",
249
+ });
250
+
251
+ return probe.status === 0;
252
+ }
253
+
254
+ function writeSystemdUserUnit() {
255
+ mkdirSync(systemdUserDir, { recursive: true });
256
+
257
+ const bunInstall = process.env.BUN_INSTALL || join(homeDir, ".bun");
258
+ const pathValue = process.env.PATH || `${join(bunInstall, "bin")}:/usr/local/bin:/usr/bin:/bin`;
259
+ const bunExecutable = resolveBunExecutable();
260
+
261
+ const unit = `[Unit]
262
+ Description=Arisa Daemon Service
263
+ After=network-online.target
264
+ Wants=network-online.target
265
+
266
+ [Service]
267
+ Type=simple
268
+ WorkingDirectory=${pkgRoot}
269
+ ExecStart=${bunExecutable} ${daemonEntry}
270
+ Restart=always
271
+ RestartSec=3
272
+ Environment=ARISA_PROJECT_DIR=${pkgRoot}
273
+ Environment=BUN_INSTALL=${bunInstall}
274
+ Environment=PATH=${pathValue}
275
+
276
+ [Install]
277
+ WantedBy=default.target
278
+ `;
279
+
280
+ writeFileSync(systemdUserUnitPath, unit, "utf8");
281
+ }
282
+
283
+ function runSystemd(commandArgs) {
284
+ const child = runCommand("systemctl", ["--user", ...commandArgs], {
285
+ stdio: "pipe",
286
+ });
287
+
288
+ if (child.status !== 0) {
289
+ const stderr = child.stderr || "Unknown systemd error";
290
+ process.stderr.write(stderr.endsWith("\n") ? stderr : `${stderr}\n`);
291
+ return { ok: false, status: child.status ?? 1 };
292
+ }
293
+
294
+ return { ok: true, status: 0, stdout: child.stdout || "" };
295
+ }
296
+
297
+ function startSystemdService() {
298
+ writeSystemdUserUnit();
299
+
300
+ const reload = runSystemd(["daemon-reload"]);
301
+ if (!reload.ok) return reload.status;
302
+
303
+ const start = runSystemd(["enable", "--now", systemdServiceName]);
304
+ if (!start.ok) return start.status;
305
+
306
+ process.stdout.write(
307
+ "Arisa service started and enabled (systemd --user).\n"
308
+ );
309
+ process.stdout.write(
310
+ "To keep it running after reboot without login, run: sudo loginctl enable-linger $USER\n"
311
+ );
312
+ return 0;
313
+ }
314
+
315
+ function stopSystemdService() {
316
+ const stop = runSystemd(["stop", systemdServiceName]);
317
+ if (!stop.ok) return stop.status;
318
+
319
+ process.stdout.write("Arisa service stopped (systemd --user).\n");
320
+ return 0;
321
+ }
322
+
323
+ function restartSystemdService() {
324
+ const restart = runSystemd(["restart", systemdServiceName]);
325
+ if (!restart.ok) return restart.status;
326
+
327
+ process.stdout.write("Arisa service restarted (systemd --user).\n");
328
+ return 0;
329
+ }
330
+
331
+ function statusSystemdService() {
332
+ const activeResult = runCommand(
333
+ "systemctl",
334
+ ["--user", "is-active", systemdServiceName],
335
+ { stdio: "pipe" }
336
+ );
337
+ const enabledResult = runCommand(
338
+ "systemctl",
339
+ ["--user", "is-enabled", systemdServiceName],
340
+ { stdio: "pipe" }
341
+ );
342
+
343
+ const active = activeResult.status === 0;
344
+ const enabled = enabledResult.status === 0;
345
+
346
+ if (active) {
347
+ process.stdout.write("Arisa service status: active (systemd --user).\n");
348
+ } else {
349
+ process.stdout.write("Arisa service status: inactive (systemd --user).\n");
350
+ }
351
+
352
+ if (enabled) {
353
+ process.stdout.write("Autostart: enabled\n");
354
+ return active ? 0 : 1;
355
+ }
356
+
357
+ process.stdout.write("Autostart: disabled\n");
358
+ return active ? 0 : 1;
359
+ }
360
+
361
+ function restartDetachedFallback() {
362
+ const stopCode = stopDetachedFallback();
363
+ if (stopCode !== 0) return stopCode;
364
+ return startDetachedFallback();
365
+ }
366
+
367
+ function startService() {
368
+ if (rest.includes("--foreground")) {
369
+ const foregroundArgs = rest.filter((arg) => arg !== "--foreground");
370
+ const child = runWithBun([daemonEntry, ...foregroundArgs]);
371
+ return child.status === null ? 1 : child.status;
372
+ }
373
+
374
+ if (canUseSystemdUser()) {
375
+ return startSystemdService();
376
+ }
377
+ return startDetachedFallback();
378
+ }
379
+
380
+ function stopService() {
381
+ if (canUseSystemdUser()) {
382
+ return stopSystemdService();
383
+ }
384
+ return stopDetachedFallback();
385
+ }
386
+
387
+ function statusService() {
388
+ if (canUseSystemdUser()) {
389
+ return statusSystemdService();
390
+ }
391
+ return statusDetachedFallback();
392
+ }
393
+
394
+ function restartService() {
395
+ if (canUseSystemdUser()) {
396
+ return restartSystemdService();
397
+ }
398
+ return restartDetachedFallback();
399
+ }
400
+
401
+ function printForegroundNotice() {
402
+ process.stdout.write("Starting Arisa in foreground. Press Ctrl+C to stop.\n");
403
+ process.stdout.write("Use `arisa start` to run it as a background service.\n");
404
+ }
405
+
34
406
  if (command === "help" || command === "--help" || command === "-h") {
35
407
  printHelp();
36
408
  process.exit(0);
@@ -41,45 +413,39 @@ if (command === "version" || command === "--version" || command === "-v") {
41
413
  process.exit(0);
42
414
  }
43
415
 
44
- const env = {
45
- ...process.env,
46
- };
47
-
48
- let bunArgs;
49
416
  switch (command) {
50
417
  case "start":
51
- case "daemon":
52
- bunArgs = [join(pkgRoot, "src", "daemon", "index.ts"), ...rest];
418
+ process.exit(startService());
53
419
  break;
54
- case "core":
55
- bunArgs = [join(pkgRoot, "src", "core", "index.ts"), ...rest];
420
+ case "stop":
421
+ process.exit(stopService());
56
422
  break;
57
- case "dev":
58
- bunArgs = ["--watch", join(pkgRoot, "src", "core", "index.ts"), ...rest];
423
+ case "status":
424
+ process.exit(statusService());
425
+ break;
426
+ case "restart":
427
+ process.exit(restartService());
59
428
  break;
429
+ case "daemon":
430
+ case "run": {
431
+ if (isDefaultInvocation) {
432
+ printForegroundNotice();
433
+ }
434
+ const child = runWithBun([daemonEntry, ...rest]);
435
+ process.exit(child.status === null ? 1 : child.status);
436
+ }
437
+ case "core":
438
+ {
439
+ const child = runWithBun([coreEntry, ...rest]);
440
+ process.exit(child.status === null ? 1 : child.status);
441
+ }
442
+ case "dev":
443
+ {
444
+ const child = runWithBun(["--watch", coreEntry, ...rest]);
445
+ process.exit(child.status === null ? 1 : child.status);
446
+ }
60
447
  default:
61
448
  process.stderr.write(`Unknown command: ${command}\n\n`);
62
449
  printHelp();
63
450
  process.exit(1);
64
451
  }
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.2",
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,13 @@ 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 login is required.",
28
+ "Check the Arisa daemon logs now and complete the device-auth steps shown there.",
29
+ "If the login flow is not running, execute:",
30
+ "<code>codex login --device-auth</code>",
31
+ "Then send your message again.",
32
+ ].join("\n");
26
33
 
27
34
  function logActivity(backend: string, model: string | null, durationMs: number, status: string) {
28
35
  try {
@@ -230,8 +237,12 @@ export async function processWithCodex(message: string): Promise<string> {
230
237
  const duration = Date.now() - start;
231
238
 
232
239
  if (exitCode !== 0) {
240
+ const combined = `${stdout}\n${stderr}`;
233
241
  log.error(`Codex exited with code ${exitCode}: ${stderr.substring(0, 200)}`);
234
242
  logActivity("codex", null, duration, `error:${exitCode}`);
243
+ if (isCodexAuthError(combined)) {
244
+ return CODEX_AUTH_REQUIRED_MESSAGE;
245
+ }
235
246
  return "Error processing with Codex. Please try again.";
236
247
  }
237
248
 
@@ -256,6 +267,10 @@ export function isClaudeRateLimitResponse(text: string): boolean {
256
267
  return text.trim() === CLAUDE_RATE_LIMIT_MESSAGE;
257
268
  }
258
269
 
270
+ export function isCodexAuthRequiredResponse(text: string): boolean {
271
+ return text.trim() === CODEX_AUTH_REQUIRED_MESSAGE;
272
+ }
273
+
259
274
  function summarizeError(raw: string): string {
260
275
  const clean = raw.replace(/\s+/g, " ").trim();
261
276
  if (!clean) return "process ended without details.";
@@ -266,3 +281,11 @@ function summarizeError(raw: string): string {
266
281
  function isRateLimit(output: string): boolean {
267
282
  return /you'?ve hit your limit|rate limit|quota|credits.*(exceeded|exhausted)/i.test(output);
268
283
  }
284
+
285
+ function isCodexAuthError(output: string): boolean {
286
+ return (
287
+ /missing bearer authentication in header/i.test(output)
288
+ || (/401\s+Unauthorized/i.test(output) && /bearer/i.test(output))
289
+ || (/failed to refresh available models/i.test(output) && /unauthorized/i.test(output))
290
+ );
291
+ }
@@ -0,0 +1,109 @@
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
+ const pendingChatIds = new Set<string>();
27
+
28
+ type NotifyFn = (chatId: string, text: string) => Promise<void>;
29
+ let notifyFn: NotifyFn | null = null;
30
+
31
+ export function setCodexLoginNotify(fn: NotifyFn) {
32
+ notifyFn = fn;
33
+ }
34
+
35
+ function needsCodexLogin(text: string): boolean {
36
+ return AUTH_HINT_PATTERNS.some((pattern) => pattern.test(text));
37
+ }
38
+
39
+ export function maybeStartCodexDeviceAuth(rawCoreText: string, chatId?: string): void {
40
+ if (!rawCoreText || !needsCodexLogin(rawCoreText)) return;
41
+ if (chatId) pendingChatIds.add(chatId);
42
+
43
+ if (loginInProgress) {
44
+ log.info("Codex device auth already in progress; skipping duplicate trigger");
45
+ return;
46
+ }
47
+
48
+ const now = Date.now();
49
+ if (now - lastLoginAttemptAt < RETRY_COOLDOWN_MS) {
50
+ log.info("Codex device auth trigger ignored (cooldown active)");
51
+ return;
52
+ }
53
+
54
+ lastLoginAttemptAt = now;
55
+ loginInProgress = true;
56
+ void runCodexDeviceAuth().finally(() => {
57
+ loginInProgress = false;
58
+ });
59
+ }
60
+
61
+ async function runCodexDeviceAuth(): Promise<void> {
62
+ log.warn("Codex auth required. Starting `codex login --device-auth` now.");
63
+ log.warn("Complete device auth using the URL/code printed below in this Arisa terminal.");
64
+
65
+ let proc: ReturnType<typeof Bun.spawn>;
66
+ try {
67
+ proc = Bun.spawn(["codex", "login", "--device-auth"], {
68
+ cwd: config.projectDir,
69
+ stdin: "inherit",
70
+ stdout: "inherit",
71
+ stderr: "inherit",
72
+ env: { ...process.env },
73
+ });
74
+ } catch (error) {
75
+ log.error(`Failed to start codex login: ${error}`);
76
+ return;
77
+ }
78
+
79
+ const exitCode = await proc.exited;
80
+ if (exitCode === 0) {
81
+ log.info("Codex device auth finished successfully. You can retry your message.");
82
+ await notifySuccess();
83
+ } else {
84
+ log.error(`Codex device auth finished with exit code ${exitCode}`);
85
+ pendingChatIds.clear();
86
+ }
87
+ }
88
+
89
+ async function notifySuccess(): Promise<void> {
90
+ if (!notifyFn || pendingChatIds.size === 0) return;
91
+
92
+ const text = [
93
+ "<b>Codex login completed successfully.</b>",
94
+ "Then try again.",
95
+ ].join("\n");
96
+
97
+ const chats = Array.from(pendingChatIds);
98
+ pendingChatIds.clear();
99
+
100
+ await Promise.all(
101
+ chats.map(async (chatId) => {
102
+ try {
103
+ await notifyFn?.(chatId, text);
104
+ } catch (error) {
105
+ log.error(`Failed to send Codex login success notice to ${chatId}: ${error}`);
106
+ }
107
+ }),
108
+ );
109
+ }
@@ -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, setCodexLoginNotify } = await import("./codex-login");
31
32
  const { chunkMessage, markdownToTelegramHtml } = await import("../core/format");
32
33
  const { saveMessageRecord } = await import("../shared/db");
33
34
 
@@ -60,6 +61,9 @@ const sendToAllChats = async (text: string) => {
60
61
 
61
62
  setLifecycleNotify(sendToAllChats);
62
63
  setAutoFixNotify(sendToAllChats);
64
+ setCodexLoginNotify(async (chatId, text) => {
65
+ await telegram.send(chatId, text);
66
+ });
63
67
 
64
68
  telegram.onMessage(async (msg) => {
65
69
  knownChatIds.add(msg.chatId);
@@ -77,6 +81,7 @@ telegram.onMessage(async (msg) => {
77
81
  clearInterval(typingInterval);
78
82
 
79
83
  const raw = response.text || "";
84
+ maybeStartCodexDeviceAuth(raw, msg.chatId);
80
85
  const messageParts = raw.split(/\n---CHUNK---\n/g);
81
86
  let sentText = false;
82
87