arisa 4.0.18 → 4.0.22

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/AGENTS.md CHANGED
@@ -8,6 +8,12 @@
8
8
  - Tools are isolated and each one has its own manifest, entrypoint, and config defaults.
9
9
  - No tools ship with the core. All installed tools live under `~/.arisa/tools/<toolName>`; the install directory of Arisa (your working directory) contains only the core. Never create or install tools inside the install directory.
10
10
 
11
+ ## Core modification policy
12
+ Modifying the Arisa core is the last resort, never the default. All work must be done in tools.
13
+ - Always solve the request by creating or editing a tool under `~/.arisa/tools/<toolName>`.
14
+ - Treat changing core code as the final option, only after confirming the capability genuinely cannot be delivered through the tool architecture.
15
+ - Never modify the core on your own initiative. Always consult the user first, explaining why a core change is unavoidable, and wait for explicit approval before touching core code.
16
+
11
17
  ## Runtime directory rules
12
18
  Do not build runtime paths by hand. Use `src/runtime/paths.js`:
13
19
  - `getToolDir(toolName)`: installed user tool package only; no runtime data here.
@@ -33,7 +39,7 @@ Each tool declares in `tool.manifest.json`:
33
39
  - `skillHints`: optional skills to apply when using or editing the tool
34
40
 
35
41
  ## Tool-to-Arisa IPC
36
- Arisa does not mount tool-provided web routes in core. If a tool needs to expose a web UI or HTTP endpoint, the tool owns that server, usually through the shared daemon runtime. The tool's server handles its own requests and uses Arisa IPC when it needs artifacts, tasks, agent events, or runtime paths.
42
+ Arisa does not mount tool-provided web routes in core. If a tool needs to expose a web UI or HTTP endpoint, the tool owns that server, usually through the shared daemon runtime. The tool's server handles its own requests and uses Arisa IPC when it needs to run a registered tool, create/read artifacts, manage tasks, enqueue agent events, or resolve runtime paths.
37
43
 
38
44
  Installed tools can import the IPC client through `ARISA_PACKAGE_DIR`:
39
45
 
@@ -48,7 +54,17 @@ const arisa = createArisaClient({ toolName: "example-tool", chatId });
48
54
  await arisa.artifacts.createText({ text: "hello" });
49
55
  ```
50
56
 
51
- The IPC channel is a local socket under `~/.arisa/state`. Every request must include `toolName`; chat-scoped capabilities also require `chatId`. Exposed capabilities are explicit: artifacts (`createText`, `listRecent`, `get`), tasks (`add`, `list`, `cancel`), agent events (`enqueueEvent`), and runtime paths (`getChatToolStateDir`, `getToolStateDir`, `getChatToolTmpDir`, `getToolTmpDir`, `getChatArtifactsDir`). Do not add raw access to `agentManager`, `taskStore`, `artifactStore`, or `toolRegistry`.
57
+ For tool-owned web UI request/response flows, call another registered tool through IPC instead of adding core HTTP routes or proxies:
58
+
59
+ ```js
60
+ const result = await arisa.tools.run({
61
+ name: "strudel-agent",
62
+ text: prompt,
63
+ args: { bpm, tags, currentCode }
64
+ }, { timeoutMs: 120_000 });
65
+ ```
66
+
67
+ The IPC channel is a local socket under `~/.arisa/state`. Every request must include `toolName`; chat-scoped capabilities also require `chatId`. Exposed capabilities are explicit: tools (`run`), artifacts (`createText`, `listRecent`, `get`), tasks (`add`, `list`, `cancel`), agent events (`enqueueEvent`), and runtime paths (`getChatToolStateDir`, `getToolStateDir`, `getChatToolTmpDir`, `getToolTmpDir`, `getChatArtifactsDir`). Do not add raw access to `agentManager`, `taskStore`, `artifactStore`, or `toolRegistry`.
52
68
 
53
69
  ## Conceptual pipe model
54
70
  There are two different moments where pipes can happen:
package/README.md CHANGED
@@ -114,6 +114,7 @@ arisa start # start in background
114
114
  arisa stop # stop background service
115
115
  arisa status # show background service status
116
116
  arisa flush # remove ~/.arisa
117
+ arisa --silent # run without verbose logs
117
118
  ```
118
119
 
119
120
  Runtime model override (current process only):
@@ -138,32 +139,7 @@ On first run, Arisa will:
138
139
  6. validate that Pi Agent works
139
140
  7. only then start listening to Telegram
140
141
 
141
- ### Non-interactive bootstrap (CLI overrides)
142
-
143
- You can skip the interactive questions by providing `--telegram.token` and optional overrides:
144
-
145
- ```bash
146
- node src/index.js --telegram.token <token>
147
- ```
148
-
149
- With this mode, Arisa creates `~/.arisa/state/config.json` without prompts and applies these defaults when not provided:
150
-
151
- - `pi.provider`: `openai-codex` when available, otherwise first provider from the current Pi provider list
152
- - `pi.model`: first model after bootstrap sorting (currently prioritizes `openai-codex/gpt-5.5`)
153
- - `telegram.maxChatIds`: `1`
154
-
155
- Supported overrides:
156
-
157
- ```bash
158
- node src/index.js --telegram.token <token> --telegram.maxChatIds 3 --pi.provider openai-codex --pi.model gpt-5.5 --pi.apiKey <optional-provider-key>
159
- ```
160
-
161
- Notes:
162
-
163
- - interactive bootstrap remains unchanged when no CLI overrides are provided
164
- - `--bootstrap` can be combined with overrides to regenerate config non-interactively
165
- - when `--pi.apiKey` is omitted and the provider supports OAuth, bootstrap can use a temporary auth relay if `PORT` is set; this relay is only for Pi login and is not a tool web route server
166
- - unknown `--pi.provider` or `--pi.model` values are ignored and replaced by safe defaults
142
+ Arisa does not run a persistent HTTP health server or Telegram webhook; Telegram uses long polling.
167
143
 
168
144
  Telegram bot tokens can be created with:
169
145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.0.18",
3
+ "version": "4.0.22",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -58,8 +58,9 @@ export function createArisaClient({ toolName, chatId = null, socketPath = proces
58
58
  throw new Error("toolName is required");
59
59
  }
60
60
 
61
- const call = (method, params = {}) => requestIpc({
61
+ const call = (method, params = {}, options = {}) => requestIpc({
62
62
  socketPath,
63
+ timeoutMs: options.timeoutMs,
63
64
  request: {
64
65
  id: crypto.randomUUID(),
65
66
  method,
@@ -83,6 +84,9 @@ export function createArisaClient({ toolName, chatId = null, socketPath = proces
83
84
  agent: {
84
85
  enqueueEvent: (params) => call("agent.enqueueEvent", params)
85
86
  },
87
+ tools: {
88
+ run: (params, options) => call("tools.run", params, options)
89
+ },
86
90
  paths: {
87
91
  getChatToolStateDir: () => call("paths.getChatToolStateDir"),
88
92
  getToolStateDir: () => call("paths.getToolStateDir"),
package/src/index.js CHANGED
@@ -1,14 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createServer } from "node:http";
4
- import { execFile } from "node:child_process";
5
3
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
6
4
  import { createApp } from "./runtime/create-app.js";
7
5
  import { createLogger } from "./runtime/logger.js";
8
6
  import { getServiceStatus, registerServiceProcess, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
9
7
  import { flushArisaHome } from "./runtime/flush.js";
10
8
  import { arisaPackageDir } from "./runtime/paths.js";
11
- import { parseProcessIds } from "./runtime/process-ids.js";
12
9
 
13
10
  process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
14
11
 
@@ -16,136 +13,13 @@ const args = process.argv.slice(2);
16
13
  const cli = parseCliArgs(args);
17
14
  const command = cli.positionals[0] || "run";
18
15
  const forceBootstrap = Boolean(cli.flags.bootstrap);
19
- const verbose = Boolean(cli.flags.verbose);
16
+ const verbose = !cli.flags.silent;
20
17
  const serviceRunner = Boolean(cli.flags["service-runner"]);
21
- const bootstrapOverrides = toBootstrapOverrides(cli.nestedFlags);
22
- const runtimeOverrides = toRuntimeOverrides(cli.nestedFlags);
18
+ const runtimeOverrides = toNestedOverrides(cli.nestedFlags);
23
19
  const logger = createLogger({ verbose });
24
20
  let activeApp = null;
25
21
  let shuttingDown = false;
26
22
 
27
- const defaultHttpPort = 11970;
28
- const httpPort = Number(process.env.ARISA_HTTP_PORT || defaultHttpPort);
29
- const shouldStartHttpServer = Boolean(httpPort && !["stop", "status", "flush"].includes(command));
30
- let httpRequestHandler = null;
31
- let httpServerListening = false;
32
- let httpServer = null;
33
-
34
- function setHttpRequestHandler(handler) {
35
- httpRequestHandler = handler;
36
- }
37
-
38
- const httpServerReady = shouldStartHttpServer
39
- ? startHttpServer()
40
- : Promise.resolve(false);
41
-
42
- async function runCommand(commandName, args = []) {
43
- return new Promise((resolve) => {
44
- execFile(commandName, args, (error, stdout = "") => {
45
- resolve(error ? "" : stdout.trim());
46
- });
47
- });
48
- }
49
-
50
- async function getListeningPids(port) {
51
- const lsofOutput = await runCommand("lsof", ["-tiTCP", `-sTCP:LISTEN`, `-iTCP:${port}`]);
52
- if (lsofOutput) {
53
- return parseProcessIds(lsofOutput);
54
- }
55
-
56
- const fuserOutput = await runCommand("fuser", ["-n", "tcp", String(port)]);
57
- return parseProcessIds(fuserOutput);
58
- }
59
-
60
- async function getProcessCommand(pid) {
61
- return runCommand("ps", ["-p", String(pid), "-o", "command="]);
62
- }
63
-
64
- function looksLikeArisaProcess(commandText) {
65
- return /\barisa\b/.test(commandText) || /\/arisa\/src\/index\.js\b/i.test(commandText);
66
- }
67
-
68
- function isProcessRunning(pid) {
69
- try {
70
- process.kill(pid, 0);
71
- return true;
72
- } catch {
73
- return false;
74
- }
75
- }
76
-
77
- async function waitForProcessExit(pid, timeoutMs = 3000) {
78
- const deadline = Date.now() + timeoutMs;
79
- while (Date.now() < deadline) {
80
- if (!isProcessRunning(pid)) return true;
81
- await new Promise((resolve) => setTimeout(resolve, 100));
82
- }
83
- return !isProcessRunning(pid);
84
- }
85
-
86
- async function stopStaleArisaListeners(port) {
87
- const pids = await getListeningPids(port);
88
- for (const pid of pids) {
89
- if (pid === process.pid) continue;
90
- const commandText = await getProcessCommand(pid);
91
- if (!looksLikeArisaProcess(commandText)) {
92
- logger.error("http", `port ${port} is already used by pid ${pid}; not killing non-Arisa process`);
93
- continue;
94
- }
95
-
96
- logger.log("http", `stopping stale Arisa listener on port ${port} (pid ${pid})`);
97
- try {
98
- process.kill(pid, "SIGTERM");
99
- if (!await waitForProcessExit(pid)) {
100
- process.kill(pid, "SIGKILL");
101
- await waitForProcessExit(pid, 1000);
102
- }
103
- } catch (error) {
104
- const message = error instanceof Error ? error.message : String(error);
105
- logger.error("http", `failed to stop stale Arisa listener pid ${pid}: ${message}`);
106
- }
107
- }
108
- }
109
-
110
- async function startHttpServer() {
111
- await stopStaleArisaListeners(httpPort);
112
- return new Promise((resolve) => {
113
- const server = createServer((req, res) => {
114
- if (httpRequestHandler) return httpRequestHandler(req, res);
115
- res.writeHead(200, { "Content-Type": "text/plain" });
116
- res.end("ok");
117
- return undefined;
118
- });
119
- httpServer = server;
120
- server.on("error", (error) => {
121
- const message = error instanceof Error ? error.message : String(error);
122
- logger.error("http", `server failed on port ${httpPort}: ${message}`);
123
- resolve(false);
124
- });
125
- server.listen(httpPort)
126
- .on("listening", () => {
127
- httpServerListening = true;
128
- logger.log("http", `health server on port ${httpPort}`);
129
- resolve(true);
130
- });
131
- });
132
- }
133
-
134
- async function getHttpOptions() {
135
- await httpServerReady;
136
- return httpServerListening ? { httpPort, setHttpRequestHandler } : {};
137
- }
138
-
139
- async function stopHttpServer() {
140
- if (!httpServerListening || !httpServer) return;
141
- await new Promise((resolve) => {
142
- httpServer.close(() => resolve());
143
- });
144
- httpServer = null;
145
- httpServerListening = false;
146
- httpRequestHandler = null;
147
- }
148
-
149
23
  function parseCliArgs(rawArgs) {
150
24
  const flags = {};
151
25
  const nestedFlags = {};
@@ -176,7 +50,7 @@ function parseCliArgs(rawArgs) {
176
50
  return { flags, nestedFlags, positionals };
177
51
  }
178
52
 
179
- function toBootstrapOverrides(nestedFlags) {
53
+ function toNestedOverrides(nestedFlags) {
180
54
  const overrides = {};
181
55
  for (const [flatKey, value] of Object.entries(nestedFlags)) {
182
56
  const parts = flatKey.split(".");
@@ -193,10 +67,6 @@ function toBootstrapOverrides(nestedFlags) {
193
67
  return overrides;
194
68
  }
195
69
 
196
- function toRuntimeOverrides(nestedFlags) {
197
- return toBootstrapOverrides(nestedFlags);
198
- }
199
-
200
70
  function toServiceRunnerArgs(nestedFlags) {
201
71
  const args = [];
202
72
  if (nestedFlags["pi.model"]) {
@@ -205,14 +75,11 @@ function toServiceRunnerArgs(nestedFlags) {
205
75
  return args;
206
76
  }
207
77
 
208
- const webhookUrl = bootstrapOverrides.webhook?.url || "";
209
-
210
78
  async function shutdown(exitCode = 0) {
211
79
  if (shuttingDown) return;
212
80
  shuttingDown = true;
213
81
  try {
214
82
  await activeApp?.stop?.();
215
- await stopHttpServer();
216
83
  } catch (error) {
217
84
  logger.error("app", `shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
218
85
  exitCode = exitCode || 1;
@@ -231,8 +98,8 @@ process.once("SIGINT", () => {
231
98
  shutdown(0);
232
99
  });
233
100
 
234
- async function startRuntimeApp(httpOptions = {}) {
235
- const app = await createApp({ logger, runtimeOverrides, webhookUrl, ...httpOptions });
101
+ async function startRuntimeApp() {
102
+ const app = await createApp({ logger, runtimeOverrides });
236
103
  activeApp = app;
237
104
  await app.start();
238
105
  }
@@ -244,10 +111,9 @@ async function runForeground() {
244
111
  || runtimeOverrides?.pi?.apiKey
245
112
  );
246
113
  logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
247
- const httpOptions = await getHttpOptions();
248
- await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
114
+ await bootstrapIfNeeded({ force: forceBootstrap });
249
115
  try {
250
- await startRuntimeApp(httpOptions);
116
+ await startRuntimeApp();
251
117
  } catch (error) {
252
118
  const message = error instanceof Error ? error.message : String(error);
253
119
  if (message.includes("No auth found")) {
@@ -260,8 +126,8 @@ async function runForeground() {
260
126
  throw error;
261
127
  }
262
128
  console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
263
- await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
264
- await startRuntimeApp(httpOptions);
129
+ await bootstrapIfNeeded({ force: true });
130
+ await startRuntimeApp();
265
131
  return;
266
132
  }
267
133
  throw error;
@@ -276,9 +142,7 @@ async function main() {
276
142
  }
277
143
 
278
144
  if (command === "start") {
279
- const httpOptions = await getHttpOptions();
280
- await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
281
- await stopHttpServer();
145
+ await bootstrapIfNeeded({ force: forceBootstrap });
282
146
  const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
283
147
  if (!result.ok) {
284
148
  console.log(`Arisa is already running in background (pid ${result.pid}).`);
@@ -27,16 +27,49 @@ function requireString(value, fieldName) {
27
27
  return value;
28
28
  }
29
29
 
30
+ function normalizeArgs(args) {
31
+ if (args == null) return {};
32
+ if (typeof args !== "object" || Array.isArray(args)) {
33
+ throw new Error("args must be an object");
34
+ }
35
+ return args;
36
+ }
37
+
30
38
  function normalizeLimit(limit) {
31
39
  const value = Number(limit);
32
40
  if (!Number.isInteger(value) || value <= 0) return 20;
33
41
  return Math.min(value, 100);
34
42
  }
35
43
 
36
- export function createArisaCapabilities({ artifactStore, taskStore } = {}) {
44
+ export function createArisaCapabilities({ artifactStore, taskStore, agentManager } = {}) {
37
45
  async function dispatch({ method, toolName, chatId = null, params = {} } = {}) {
38
46
  const scopedToolName = requireToolName(toolName);
39
47
 
48
+ if (method === "tools.run") {
49
+ if (!agentManager?.runTool) {
50
+ throw new Error("tools.run requires agentManager");
51
+ }
52
+ const scopedChatId = requireChatId(chatId, method);
53
+ const targetToolName = requireString(params.name, "name");
54
+ const chatArtifactStore = artifactStore.forChat(scopedChatId);
55
+ const artifact = params.artifactId
56
+ ? await chatArtifactStore.get(requireString(params.artifactId, "artifactId"))
57
+ : null;
58
+ if (params.artifactId && !artifact) {
59
+ throw new Error(`Artifact not found: ${params.artifactId}`);
60
+ }
61
+
62
+ return agentManager.runTool({
63
+ name: targetToolName,
64
+ request: {
65
+ artifact,
66
+ text: params.text,
67
+ args: normalizeArgs(params.args)
68
+ },
69
+ chatId: scopedChatId
70
+ });
71
+ }
72
+
40
73
  if (method === "artifacts.createText") {
41
74
  const scopedChatId = requireChatId(chatId, method);
42
75
  return artifactStore.forChat(scopedChatId).createText({
@@ -24,17 +24,6 @@ async function exists(file) {
24
24
  }
25
25
  }
26
26
 
27
- function normalizeString(value) {
28
- if (typeof value !== "string") return "";
29
- return value.trim();
30
- }
31
-
32
- function parseMaxChatIds(value, fallback = 1) {
33
- const parsed = Number(value);
34
- if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
35
- return parsed;
36
- }
37
-
38
27
  function buildConfig({ telegramApiKey, telegramMaxChatIds, provider, model, piApiKey }) {
39
28
  return {
40
29
  telegram: {
@@ -52,37 +41,6 @@ function buildConfig({ telegramApiKey, telegramMaxChatIds, provider, model, piAp
52
41
  };
53
42
  }
54
43
 
55
- function resolvePiDefaults(runtime, { provider: preferredProvider = "", model: preferredModel = "" } = {}) {
56
- const providers = sortBootstrapProviders(listPiProviders(runtime));
57
- if (!providers.length) {
58
- throw new Error("No Pi providers are available for bootstrap.");
59
- }
60
-
61
- const preferredProviderValue = normalizeString(preferredProvider);
62
- const providerExists = providers.some((item) => item.provider === preferredProviderValue);
63
- if (preferredProviderValue && !providerExists) {
64
- console.log(`Ignoring unknown Pi provider override: ${preferredProviderValue}`);
65
- }
66
-
67
- const selectedProvider = providerExists
68
- ? preferredProviderValue
69
- : providers[0].provider;
70
-
71
- const models = sortBootstrapModels(selectedProvider, listProviderModels(selectedProvider, runtime));
72
- if (!models.length) {
73
- throw new Error(`No Pi models are available for provider ${selectedProvider}.`);
74
- }
75
-
76
- const preferredModelValue = normalizeString(preferredModel);
77
- const modelExists = models.some((item) => item.id === preferredModelValue);
78
- if (preferredModelValue && !modelExists) {
79
- console.log(`Ignoring unknown Pi model override for ${selectedProvider}: ${preferredModelValue}`);
80
- }
81
-
82
- const selectedModel = modelExists ? preferredModelValue : models[0].id;
83
- return { provider: selectedProvider, model: selectedModel };
84
- }
85
-
86
44
  function sortBootstrapProviders(providers) {
87
45
  const preferredOrder = ["openai-codex"];
88
46
  const positions = new Map(providers.map((provider, index) => [provider.provider, index]));
@@ -131,89 +89,13 @@ async function maybeOpenExternal(url) {
131
89
  });
132
90
  }
133
91
 
134
- function installAuthRelay(httpPort, setHttpRequestHandler) {
135
- let authUrl = "";
136
- let resolveRedirectUrl;
137
- const redirectUrlPromise = new Promise((resolve) => {
138
- resolveRedirectUrl = resolve;
139
- });
140
-
141
- const page = (body) => [
142
- "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width'>",
143
- "<title>Arisa Auth</title>",
144
- "<style>body{font-family:system-ui,sans-serif;max-width:600px;margin:40px auto;padding:0 20px;line-height:1.6}",
145
- "input[type=text]{width:100%;padding:8px;box-sizing:border-box;margin:8px 0}",
146
- "button{padding:8px 24px;cursor:pointer}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style>",
147
- "</head><body>",
148
- body,
149
- "</body></html>"
150
- ].join("");
151
-
152
- setHttpRequestHandler((req, res) => {
153
- const parsed = new URL(req.url, `http://localhost:${httpPort}`);
154
-
155
- if (req.method === "GET" && parsed.pathname === "/auth/callback" && parsed.searchParams.has("code")) {
156
- const callbackUrl = `http://localhost:1455${parsed.pathname}${parsed.search}`;
157
- resolveRedirectUrl(callbackUrl);
158
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
159
- res.end(page("<h2>Authentication received</h2><p>You can close this page. Arisa is starting&hellip;</p>"));
160
- return;
161
- }
162
-
163
- if (req.method === "GET" && parsed.pathname === "/") {
164
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
165
- res.end(page([
166
- "<h2>Arisa &mdash; Pi Authentication</h2>",
167
- authUrl
168
- ? `<p><strong>1.</strong> <a href="${authUrl}" target="_blank">Click here to log in with Pi</a></p>`
169
- : "<p>Waiting for authentication URL&hellip;</p>",
170
- "<p><strong>2.</strong> After login your browser will redirect to a <code>localhost</code> URL that won't load. That's expected.</p>",
171
- "<p><strong>3.</strong> In your browser's address bar, replace <code>localhost:1455</code> with your server's domain and press Enter.</p>",
172
- "<hr>",
173
- "<p><em>Or paste the full redirect URL here:</em></p>",
174
- '<form method="POST" action="/auth/relay">',
175
- '<input type="text" name="url" placeholder="Paste the localhost redirect URL here&hellip;" required />',
176
- "<button type='submit'>Submit</button>",
177
- "</form>"
178
- ].join("\n")));
179
- return;
180
- }
181
-
182
- if (req.method === "POST" && parsed.pathname === "/auth/relay") {
183
- let body = "";
184
- req.on("data", (chunk) => { body += chunk; });
185
- req.on("end", () => {
186
- const url = (new URLSearchParams(body).get("url") || "").trim();
187
- if (url) resolveRedirectUrl(url);
188
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
189
- res.end(page("<h2>Authentication received</h2><p>You can close this page. Arisa is starting&hellip;</p>"));
190
- });
191
- return;
192
- }
193
-
194
- res.writeHead(200, { "Content-Type": "text/plain" });
195
- res.end("ok");
196
- });
197
-
198
- return {
199
- setAuthUrl(url) { authUrl = url; },
200
- waitForRedirectUrl() { return redirectUrlPromise; },
201
- uninstall() { setHttpRequestHandler(null); }
202
- };
203
- }
204
-
205
- async function runInternalPiLogin(provider, { rl = null, authRelay = null } = {}) {
92
+ async function runInternalPiLogin(provider, { rl = null } = {}) {
206
93
  const login = createPiOAuthLogin({
207
94
  provider,
208
95
  onAuth: async ({ url, instructions, controller }) => {
209
96
  console.log(`${instructions || "Open this URL to continue authentication:"}\n${url}\n`);
210
97
  await maybeOpenExternal(url);
211
- if (authRelay) {
212
- authRelay.setAuthUrl(url);
213
- console.log("Waiting for authentication via the web relay...");
214
- const redirectUrl = await authRelay.waitForRedirectUrl();
215
- if (redirectUrl) controller.submitManualCode(redirectUrl);
216
- } else if (controller.oauthProvider.usesCallbackServer && rl) {
98
+ if (controller.oauthProvider.usesCallbackServer && rl) {
217
99
  const pasted = (await rl.question("Paste the redirect URL here if the browser does not return automatically, or press Enter to keep waiting: ")).trim();
218
100
  if (pasted) controller.submitManualCode(pasted);
219
101
  }
@@ -236,54 +118,10 @@ async function runInternalPiLogin(provider, { rl = null, authRelay = null } = {}
236
118
  await login.promise;
237
119
  }
238
120
 
239
- export async function bootstrapIfNeeded({ force = false, cliConfigOverrides = {}, httpPort = 0, setHttpRequestHandler } = {}) {
121
+ export async function bootstrapIfNeeded({ force = false } = {}) {
240
122
  await ensureArisaHome();
241
123
  if (!force && await exists(configFile)) return;
242
124
 
243
- const telegramApiKeyFromCli = normalizeString(cliConfigOverrides?.telegram?.token);
244
- if (telegramApiKeyFromCli) {
245
- const runtime = createPiRuntime();
246
- const resolvedPi = resolvePiDefaults(runtime, cliConfigOverrides?.pi || {});
247
- const telegramMaxChatIds = parseMaxChatIds(cliConfigOverrides?.telegram?.maxChatIds, 1);
248
- const piApiKey = normalizeString(cliConfigOverrides?.pi?.apiKey);
249
- if (!piApiKey && !hasProviderAuth(resolvedPi.provider, runtime)) {
250
- if (!supportsProviderOAuth(resolvedPi.provider, runtime)) {
251
- throw new Error(
252
- `No auth found for ${resolvedPi.provider}. Provide --pi.apiKey for non-interactive bootstrap, or use a provider that supports OAuth.`
253
- );
254
- }
255
- if (!httpPort || !setHttpRequestHandler) {
256
- throw new Error(
257
- `No auth found for ${resolvedPi.provider}. Arisa's HTTP server is unavailable; set ARISA_HTTP_PORT or free the configured port.`
258
- );
259
- }
260
- const authRelay = installAuthRelay(httpPort, setHttpRequestHandler);
261
- console.log(`No existing Pi auth found for ${resolvedPi.provider}. Auth relay active on port ${httpPort}.`);
262
- console.log(`Open your server URL in a browser to complete Pi authentication.\n`);
263
- try {
264
- await runInternalPiLogin(resolvedPi.provider, { authRelay });
265
- } finally {
266
- authRelay.uninstall();
267
- }
268
- if (!hasProviderAuth(resolvedPi.provider, createPiRuntime())) {
269
- throw new Error(
270
- `Pi login did not complete for ${resolvedPi.provider}. Retry or provide --pi.apiKey.`
271
- );
272
- }
273
- console.log(`Detected Pi auth for ${resolvedPi.provider}. Continuing bootstrap.`);
274
- }
275
- const config = buildConfig({
276
- telegramApiKey: telegramApiKeyFromCli,
277
- telegramMaxChatIds,
278
- provider: resolvedPi.provider,
279
- model: resolvedPi.model,
280
- piApiKey
281
- });
282
- await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
283
- console.log(`\nConfig saved to ${configFile} (non-interactive bootstrap)\n`);
284
- return;
285
- }
286
-
287
125
  const rl = readline.createInterface({ input, output });
288
126
  const ask = async (label, fallback = "") => {
289
127
  const suffix = fallback ? ` (${fallback})` : "";
@@ -46,7 +46,7 @@ function applyRuntimeOverrides(config, runtimeOverrides) {
46
46
  };
47
47
  }
48
48
 
49
- export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpRequestHandler } = {}) {
49
+ export async function createApp({ logger, runtimeOverrides } = {}) {
50
50
  logger?.log("app", "loading config");
51
51
  const persistedConfig = await loadConfig();
52
52
  const config = applyRuntimeOverrides(persistedConfig, runtimeOverrides);
@@ -62,9 +62,9 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
62
62
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
63
63
 
64
64
  const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
65
- const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore });
65
+ const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore, agentManager });
66
66
  const ipcServer = createIpcServer({ capabilities: arisaCapabilities, logger });
67
- const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler });
67
+ const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger });
68
68
 
69
69
  return {
70
70
  async start() {
@@ -36,7 +36,7 @@ export async function getServiceStatus() {
36
36
  return { running: true, pid };
37
37
  }
38
38
 
39
- export async function startService({ verbose = false, cliArgs = [] } = {}) {
39
+ export async function startService({ verbose = true, cliArgs = [] } = {}) {
40
40
  await ensureArisaHome();
41
41
  const status = await getServiceStatus();
42
42
  if (status.running) {
@@ -45,7 +45,7 @@ export async function startService({ verbose = false, cliArgs = [] } = {}) {
45
45
 
46
46
  const logHandle = await open(serviceLogFile, "a");
47
47
  const args = [entryFile, "--service-runner", ...cliArgs];
48
- if (verbose) args.push("--verbose");
48
+ if (!verbose) args.push("--silent");
49
49
 
50
50
  const child = spawn(process.execPath, args, {
51
51
  detached: true,
@@ -1,4 +1,4 @@
1
- import { Bot, InputFile, webhookCallback } from "grammy";
1
+ import { Bot, InputFile } from "grammy";
2
2
  import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
4
  import { captureIncomingArtifact } from "./media.js";
@@ -251,7 +251,7 @@ async function withTyping(ctx, work) {
251
251
  }
252
252
  }
253
253
 
254
- export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, webRouter }) {
254
+ export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger }) {
255
255
  const bot = new Bot(config.telegram.token);
256
256
  const perChatState = new Map();
257
257
  const notifiedPromptErrors = new WeakSet();
@@ -736,25 +736,10 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
736
736
  }, 1000);
737
737
  taskTimer.unref();
738
738
  }
739
- if (webhookUrl && webRouter) {
740
- const webhookPath = `/telegram-${config.telegram.token.slice(-8)}`;
741
- const handleUpdate = webhookCallback(bot, "http", {
742
- timeoutMilliseconds: 60_000,
743
- onTimeout: "return",
744
- });
745
- webRouter.registerCoreRoute({
746
- method: "POST",
747
- path: webhookPath,
748
- handler: handleUpdate
749
- });
750
- await bot.api.setWebhook(`${webhookUrl}${webhookPath}`);
751
- logger?.log("telegram", `webhook mode: ${webhookUrl}${webhookPath}`);
752
- scheduleStartupMessages({ skipAgentStartupPrompts });
753
- } else {
754
- logger?.log("telegram", "bot polling started");
755
- scheduleStartupMessages({ skipAgentStartupPrompts });
756
- await bot.start({ drop_pending_updates: true });
757
- }
739
+ await bot.api.deleteWebhook({ drop_pending_updates: true });
740
+ logger?.log("telegram", "bot polling started");
741
+ scheduleStartupMessages({ skipAgentStartupPrompts });
742
+ await bot.start();
758
743
  },
759
744
 
760
745
  async stop() {
@@ -59,13 +59,18 @@ function createFakeTaskStore() {
59
59
  };
60
60
  }
61
61
 
62
- function createCapabilities() {
62
+ function createCapabilities(overrides = {}) {
63
63
  return createArisaCapabilities({
64
- artifactStore: createFakeArtifactStore(),
65
- taskStore: createFakeTaskStore()
64
+ artifactStore: overrides.artifactStore || createFakeArtifactStore(),
65
+ taskStore: overrides.taskStore || createFakeTaskStore(),
66
+ agentManager: overrides.agentManager
66
67
  });
67
68
  }
68
69
 
70
+ function wait(ms) {
71
+ return new Promise((resolve) => setTimeout(resolve, ms));
72
+ }
73
+
69
74
  async function createTempSocketPath() {
70
75
  const root = await mkdtemp(path.join(os.tmpdir(), "arisa-ipc-"));
71
76
  return path.join(root, "arisa.sock");
@@ -141,6 +146,114 @@ test("requires chatId for chat-scoped capabilities", async () => {
141
146
  }
142
147
  });
143
148
 
149
+ test("runs a registered tool over local IPC", async () => {
150
+ const socketPath = await createTempSocketPath();
151
+ const calls = [];
152
+ const agentManager = {
153
+ runTool: async (request) => {
154
+ calls.push(request);
155
+ return { ok: true, status: "ok", output: { text: "generated code" } };
156
+ }
157
+ };
158
+ const ipcServer = createIpcServer({ capabilities: createCapabilities({ agentManager }), socketPath });
159
+ await ipcServer.start();
160
+
161
+ try {
162
+ const client = createArisaClient({ toolName: "strudel-ui", chatId: "chat-1", socketPath });
163
+ const result = await client.tools.run({
164
+ name: "strudel-agent",
165
+ text: "make acid house",
166
+ args: {
167
+ bpm: 128,
168
+ tags: ["acid", "live"],
169
+ currentCode: "stack(s(\"bd\"))"
170
+ }
171
+ });
172
+
173
+ assert.deepEqual(result, { ok: true, status: "ok", output: { text: "generated code" } });
174
+ assert.equal(calls.length, 1);
175
+ assert.equal(calls[0].name, "strudel-agent");
176
+ assert.equal(calls[0].chatId, "chat-1");
177
+ assert.equal(calls[0].request.text, "make acid house");
178
+ assert.deepEqual(calls[0].request.args, {
179
+ bpm: 128,
180
+ tags: ["acid", "live"],
181
+ currentCode: "stack(s(\"bd\"))"
182
+ });
183
+ assert.equal(calls[0].request.artifact, null);
184
+ } finally {
185
+ await ipcServer.stop();
186
+ }
187
+ });
188
+
189
+ test("hydrates artifact input before running a tool over IPC", async () => {
190
+ const socketPath = await createTempSocketPath();
191
+ const calls = [];
192
+ const agentManager = {
193
+ runTool: async (request) => {
194
+ calls.push(request);
195
+ return { ok: true, status: "ok", output: { json: { received: true } } };
196
+ }
197
+ };
198
+ const ipcServer = createIpcServer({ capabilities: createCapabilities({ agentManager }), socketPath });
199
+ await ipcServer.start();
200
+
201
+ try {
202
+ const client = createArisaClient({ toolName: "strudel-ui", chatId: 123, socketPath });
203
+ const artifact = await client.artifacts.createText({ text: "current pattern" });
204
+ await client.tools.run({ name: "strudel-agent", artifactId: artifact.id });
205
+
206
+ assert.equal(calls.length, 1);
207
+ assert.deepEqual(calls[0].request.artifact, artifact);
208
+ } finally {
209
+ await ipcServer.stop();
210
+ }
211
+ });
212
+
213
+ test("requires chatId when running a tool over IPC", async () => {
214
+ const socketPath = await createTempSocketPath();
215
+ const agentManager = {
216
+ runTool: async () => ({ ok: true, status: "ok" })
217
+ };
218
+ const ipcServer = createIpcServer({ capabilities: createCapabilities({ agentManager }), socketPath });
219
+ await ipcServer.start();
220
+
221
+ try {
222
+ const client = createArisaClient({ toolName: "strudel-ui", socketPath });
223
+ await assert.rejects(
224
+ () => client.tools.run({ name: "strudel-agent" }),
225
+ /tools\.run requires chatId/
226
+ );
227
+ } finally {
228
+ await ipcServer.stop();
229
+ }
230
+ });
231
+
232
+ test("supports per-call IPC timeout overrides", async () => {
233
+ const socketPath = await createTempSocketPath();
234
+ const agentManager = {
235
+ runTool: async () => {
236
+ await wait(25);
237
+ return { ok: true, status: "ok" };
238
+ }
239
+ };
240
+ const ipcServer = createIpcServer({ capabilities: createCapabilities({ agentManager }), socketPath });
241
+ await ipcServer.start();
242
+
243
+ try {
244
+ const client = createArisaClient({ toolName: "strudel-ui", chatId: 123, socketPath });
245
+ const result = await client.tools.run({ name: "strudel-agent" }, { timeoutMs: 250 });
246
+ assert.deepEqual(result, { ok: true, status: "ok" });
247
+
248
+ await assert.rejects(
249
+ () => client.tools.run({ name: "strudel-agent" }, { timeoutMs: 1 }),
250
+ /Arisa IPC request timed out/
251
+ );
252
+ } finally {
253
+ await ipcServer.stop();
254
+ }
255
+ });
256
+
144
257
  test("listens on a local socket, not a TCP port", async () => {
145
258
  const socketPath = await createTempSocketPath();
146
259
  const ipcServer = createIpcServer({ capabilities: createCapabilities(), socketPath });
@@ -1,9 +0,0 @@
1
- export function parseProcessIds(commandOutput) {
2
- if (!commandOutput) return [];
3
-
4
- return commandOutput
5
- .split(/\s+/)
6
- .filter((token) => /^\d+$/.test(token))
7
- .map(Number)
8
- .filter((pid) => Number.isInteger(pid) && pid > 0);
9
- }
@@ -1,14 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { parseProcessIds } from "../src/runtime/process-ids.js";
4
-
5
- test("ignores empty PID command output", () => {
6
- assert.deepEqual(parseProcessIds(""), []);
7
- assert.deepEqual(parseProcessIds(" \n\t"), []);
8
- });
9
-
10
- test("parses only positive integer PID tokens", () => {
11
- assert.deepEqual(parseProcessIds("123\n456"), [123, 456]);
12
- assert.deepEqual(parseProcessIds("11970/tcp: 1234"), [1234]);
13
- assert.deepEqual(parseProcessIds("0 nope -12 42"), [42]);
14
- });