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 +28 -9
- package/bin/arisa.js +405 -39
- package/package.json +1 -1
- package/src/core/index.ts +16 -7
- package/src/core/processor.ts +23 -0
- package/src/daemon/codex-login.ts +109 -0
- package/src/daemon/index.ts +5 -0
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
bun
|
|
24
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
10
|
-
const
|
|
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
|
|
19
|
-
arisa
|
|
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
|
-
|
|
52
|
-
bunArgs = [join(pkgRoot, "src", "daemon", "index.ts"), ...rest];
|
|
418
|
+
process.exit(startService());
|
|
53
419
|
break;
|
|
54
|
-
case "
|
|
55
|
-
|
|
420
|
+
case "stop":
|
|
421
|
+
process.exit(stopService());
|
|
56
422
|
break;
|
|
57
|
-
case "
|
|
58
|
-
|
|
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
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 {
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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);
|
package/src/core/processor.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -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
|
|