arisa 4.0.10 → 4.0.12

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
@@ -162,7 +162,7 @@ Notes:
162
162
 
163
163
  - interactive bootstrap remains unchanged when no CLI overrides are provided
164
164
  - `--bootstrap` can be combined with overrides to regenerate config non-interactively
165
- - when `--pi.apiKey` is omitted and the provider supports OAuth, Arisa starts a temporary web page on `PORT` (default `10000`) where you can complete authentication from any browser
165
+ - Arisa's HTTP server listens on `ARISA_HTTP_PORT` (default `91107`); bootstrap OAuth pages and tool-provided web routes use this server
166
166
  - unknown `--pi.provider` or `--pi.model` values are ignored and replaced by safe defaults
167
167
 
168
168
  Telegram bot tokens can be created with:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.0.10",
3
+ "version": "4.0.12",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createServer } from "node:http";
4
+ import { execFile } from "node:child_process";
4
5
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
5
6
  import { createApp } from "./runtime/create-app.js";
6
7
  import { createLogger } from "./runtime/logger.js";
@@ -22,20 +23,126 @@ const logger = createLogger({ verbose });
22
23
  let activeApp = null;
23
24
  let shuttingDown = false;
24
25
 
25
- const httpPort = Number(process.env.PORT);
26
+ const defaultHttpPort = 91107;
27
+ const httpPort = Number(process.env.ARISA_HTTP_PORT || defaultHttpPort);
28
+ const shouldStartHttpServer = Boolean(httpPort && !["stop", "status", "flush"].includes(command));
26
29
  let httpRequestHandler = null;
30
+ let httpServerListening = false;
31
+ let httpServer = null;
27
32
 
28
33
  function setHttpRequestHandler(handler) {
29
34
  httpRequestHandler = handler;
30
35
  }
31
36
 
32
- if (httpPort) {
33
- createServer((req, res) => {
34
- if (httpRequestHandler) return httpRequestHandler(req, res);
35
- res.writeHead(200, { "Content-Type": "text/plain" });
36
- res.end("ok");
37
- }).listen(httpPort)
38
- .on("listening", () => logger.log("http", `health server on port ${httpPort}`));
37
+ const httpServerReady = shouldStartHttpServer
38
+ ? startHttpServer()
39
+ : Promise.resolve(false);
40
+
41
+ async function runCommand(commandName, args = []) {
42
+ return new Promise((resolve) => {
43
+ execFile(commandName, args, (error, stdout = "") => {
44
+ resolve(error ? "" : stdout.trim());
45
+ });
46
+ });
47
+ }
48
+
49
+ async function getListeningPids(port) {
50
+ const lsofOutput = await runCommand("lsof", ["-tiTCP", `-sTCP:LISTEN`, `-iTCP:${port}`]);
51
+ if (lsofOutput) {
52
+ return lsofOutput.split(/\s+/).map(Number).filter(Number.isFinite);
53
+ }
54
+
55
+ const fuserOutput = await runCommand("fuser", ["-n", "tcp", String(port)]);
56
+ return fuserOutput.split(/\s+/).map(Number).filter(Number.isFinite);
57
+ }
58
+
59
+ async function getProcessCommand(pid) {
60
+ return runCommand("ps", ["-p", String(pid), "-o", "command="]);
61
+ }
62
+
63
+ function looksLikeArisaProcess(commandText) {
64
+ return /\barisa\b/.test(commandText) || /\/arisa\/src\/index\.js\b/i.test(commandText);
65
+ }
66
+
67
+ function isProcessRunning(pid) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ async function waitForProcessExit(pid, timeoutMs = 3000) {
77
+ const deadline = Date.now() + timeoutMs;
78
+ while (Date.now() < deadline) {
79
+ if (!isProcessRunning(pid)) return true;
80
+ await new Promise((resolve) => setTimeout(resolve, 100));
81
+ }
82
+ return !isProcessRunning(pid);
83
+ }
84
+
85
+ async function stopStaleArisaListeners(port) {
86
+ const pids = await getListeningPids(port);
87
+ for (const pid of pids) {
88
+ if (pid === process.pid) continue;
89
+ const commandText = await getProcessCommand(pid);
90
+ if (!looksLikeArisaProcess(commandText)) {
91
+ logger.error("http", `port ${port} is already used by pid ${pid}; not killing non-Arisa process`);
92
+ continue;
93
+ }
94
+
95
+ logger.log("http", `stopping stale Arisa listener on port ${port} (pid ${pid})`);
96
+ try {
97
+ process.kill(pid, "SIGTERM");
98
+ if (!await waitForProcessExit(pid)) {
99
+ process.kill(pid, "SIGKILL");
100
+ await waitForProcessExit(pid, 1000);
101
+ }
102
+ } catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ logger.error("http", `failed to stop stale Arisa listener pid ${pid}: ${message}`);
105
+ }
106
+ }
107
+ }
108
+
109
+ async function startHttpServer() {
110
+ await stopStaleArisaListeners(httpPort);
111
+ return new Promise((resolve) => {
112
+ const server = createServer((req, res) => {
113
+ if (httpRequestHandler) return httpRequestHandler(req, res);
114
+ res.writeHead(200, { "Content-Type": "text/plain" });
115
+ res.end("ok");
116
+ return undefined;
117
+ });
118
+ httpServer = server;
119
+ server.on("error", (error) => {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ logger.error("http", `server failed on port ${httpPort}: ${message}`);
122
+ resolve(false);
123
+ });
124
+ server.listen(httpPort)
125
+ .on("listening", () => {
126
+ httpServerListening = true;
127
+ logger.log("http", `health server on port ${httpPort}`);
128
+ resolve(true);
129
+ });
130
+ });
131
+ }
132
+
133
+ async function getHttpOptions() {
134
+ await httpServerReady;
135
+ return httpServerListening ? { httpPort, setHttpRequestHandler } : {};
136
+ }
137
+
138
+ async function stopHttpServer() {
139
+ if (!httpServerListening || !httpServer) return;
140
+ await new Promise((resolve) => {
141
+ httpServer.close(() => resolve());
142
+ });
143
+ httpServer = null;
144
+ httpServerListening = false;
145
+ httpRequestHandler = null;
39
146
  }
40
147
 
41
148
  function parseCliArgs(rawArgs) {
@@ -97,15 +204,14 @@ function toServiceRunnerArgs(nestedFlags) {
97
204
  return args;
98
205
  }
99
206
 
100
- const bootstrapHttpOptions = httpPort ? { httpPort, setHttpRequestHandler } : {};
101
207
  const webhookUrl = bootstrapOverrides.webhook?.url || "";
102
- const appHttpOptions = httpPort ? { webhookUrl, setHttpRequestHandler } : {};
103
208
 
104
209
  async function shutdown(exitCode = 0) {
105
210
  if (shuttingDown) return;
106
211
  shuttingDown = true;
107
212
  try {
108
213
  await activeApp?.stop?.();
214
+ await stopHttpServer();
109
215
  } catch (error) {
110
216
  logger.error("app", `shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
111
217
  exitCode = exitCode || 1;
@@ -124,8 +230,8 @@ process.once("SIGINT", () => {
124
230
  shutdown(0);
125
231
  });
126
232
 
127
- async function startRuntimeApp() {
128
- const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
233
+ async function startRuntimeApp(httpOptions = {}) {
234
+ const app = await createApp({ logger, runtimeOverrides, webhookUrl, ...httpOptions });
129
235
  activeApp = app;
130
236
  await app.start();
131
237
  }
@@ -137,9 +243,10 @@ async function runForeground() {
137
243
  || runtimeOverrides?.pi?.apiKey
138
244
  );
139
245
  logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
140
- await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
246
+ const httpOptions = await getHttpOptions();
247
+ await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
141
248
  try {
142
- await startRuntimeApp();
249
+ await startRuntimeApp(httpOptions);
143
250
  } catch (error) {
144
251
  const message = error instanceof Error ? error.message : String(error);
145
252
  if (message.includes("No auth found")) {
@@ -152,8 +259,8 @@ async function runForeground() {
152
259
  throw error;
153
260
  }
154
261
  console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
155
- await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
156
- await startRuntimeApp();
262
+ await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
263
+ await startRuntimeApp(httpOptions);
157
264
  return;
158
265
  }
159
266
  throw error;
@@ -168,7 +275,9 @@ async function main() {
168
275
  }
169
276
 
170
277
  if (command === "start") {
171
- await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
278
+ const httpOptions = await getHttpOptions();
279
+ await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
280
+ await stopHttpServer();
172
281
  const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
173
282
  if (!result.ok) {
174
283
  console.log(`Arisa is already running in background (pid ${result.pid}).`);
@@ -254,7 +254,7 @@ export async function bootstrapIfNeeded({ force = false, cliConfigOverrides = {}
254
254
  }
255
255
  if (!httpPort || !setHttpRequestHandler) {
256
256
  throw new Error(
257
- `No auth found for ${resolvedPi.provider}. Auth relay requires an HTTP server on PORT. Provide --pi.apiKey or set the PORT environment variable.`
257
+ `No auth found for ${resolvedPi.provider}. Arisa's HTTP server is unavailable; set ARISA_HTTP_PORT or free the configured port.`
258
258
  );
259
259
  }
260
260
  const authRelay = installAuthRelay(httpPort, setHttpRequestHandler);