arisa 4.0.14 → 4.0.18

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
@@ -31,7 +31,24 @@ Each tool declares in `tool.manifest.json`:
31
31
  - `output`: produced output types
32
32
  - `configSchema`: required config fields
33
33
  - `skillHints`: optional skills to apply when using or editing the tool
34
- - `web.routes`: optional HTTP routes exposed through Arisa's main server
34
+
35
+ ## 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.
37
+
38
+ Installed tools can import the IPC client through `ARISA_PACKAGE_DIR`:
39
+
40
+ ```js
41
+ import path from "node:path";
42
+ import { pathToFileURL } from "node:url";
43
+
44
+ const importCore = (relativePath) => import(pathToFileURL(path.join(process.env.ARISA_PACKAGE_DIR, "src", relativePath)).href);
45
+ const { createArisaClient } = await importCore("core/tools/ipc-client.js");
46
+
47
+ const arisa = createArisaClient({ toolName: "example-tool", chatId });
48
+ await arisa.artifacts.createText({ text: "hello" });
49
+ ```
50
+
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`.
35
52
 
36
53
  ## Conceptual pipe model
37
54
  There are two different moments where pipes can happen:
@@ -72,27 +89,6 @@ Every CLI must support (the entrypoint comes from `manifest.entry`, currently al
72
89
  - `node index.js --help`
73
90
  - `node index.js run --request-file <json>`
74
91
 
75
- ## Tool-provided web routes
76
- Tools may expose small web pages or HTTP endpoints through Arisa's main HTTP server by declaring `web.routes` in `tool.manifest.json`. See `tools/README.md` and `tools/web-hello` for the full contract and minimal example.
77
-
78
- ```json
79
- {
80
- "name": "example-tool",
81
- "web": {
82
- "routes": [
83
- {
84
- "path": "/example",
85
- "handler": "web/index.js",
86
- "methods": ["GET"],
87
- "public": false
88
- }
89
- ]
90
- }
91
- }
92
- ```
93
-
94
- Handlers run in-process and export `handleWebRequest(req, res, context)`. Routes are protected by default with `config.web.token`; use `public: true` only for intentional public GET endpoints. Keep handlers inside the installed tool directory, avoid reserved paths (`/`, `/health`, `/api*`, `/auth*`, `/telegram-*`), and persist data only through `context.paths` runtime helpers such as `getChatToolStateDir(chatId, toolName)`.
95
-
96
92
  ### Tools that need daemons
97
93
  A tool may need a persistent process, for example to keep a browser session alive or a local model warm. The shared daemon runtime exists for this (the `whispermix-transcribe` catalog tool uses it).
98
94
  When such a tool is built, implement it with the shared daemon runtime instead of custom ad hoc process management:
@@ -103,6 +99,7 @@ When such a tool is built, implement it with the shared daemon runtime instead o
103
99
  - keep one daemon owner per tool/session and avoid opening a second client over the same resource
104
100
  - use `beforeStart` only for tool-specific cleanup such as stale browser locks, without deleting persistent session/model data
105
101
  - keep daemon tools headless/server-safe by default when they are meant to run on VPS machines
102
+ - if the daemon exposes an HTTP server, keep that server inside the tool; Arisa core does not discover or mount tool routes
106
103
 
107
104
  ## Manual pipe behavior
108
105
  To run a pipe, the agent should:
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
- - Arisa's HTTP server listens on `ARISA_HTTP_PORT` (default `11970`); bootstrap OAuth pages and tool-provided web routes use this server
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
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,17 +1,12 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.0.14",
3
+ "version": "4.0.18",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
8
8
  "arisa": "bin/arisa.js"
9
9
  },
10
- "scripts": {
11
- "start": "node src/index.js",
12
- "bootstrap": "node src/index.js --bootstrap",
13
- "test": "node --test"
14
- },
15
10
  "keywords": [
16
11
  "telegram",
17
12
  "pi-agent",
@@ -41,10 +36,14 @@
41
36
  "url": "https://github.com/clasen/Arisa/issues"
42
37
  },
43
38
  "homepage": "https://github.com/clasen/Arisa#readme",
44
- "packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
45
39
  "dependencies": {
46
40
  "@earendil-works/pi-coding-agent": "^0.79.9",
47
41
  "@sinclair/typebox": "^0.34.41",
48
42
  "grammy": "^1.42.0"
43
+ },
44
+ "scripts": {
45
+ "start": "node src/index.js",
46
+ "bootstrap": "node src/index.js --bootstrap",
47
+ "test": "node --test"
49
48
  }
50
- }
49
+ }
@@ -0,0 +1,94 @@
1
+ import crypto from "node:crypto";
2
+ import net from "node:net";
3
+ import { arisaIpcSocketFile } from "../../runtime/paths.js";
4
+
5
+ const DEFAULT_TIMEOUT_MS = 10_000;
6
+
7
+ function requestIpc({ socketPath, request, timeoutMs = DEFAULT_TIMEOUT_MS }) {
8
+ return new Promise((resolve, reject) => {
9
+ const socket = net.createConnection(socketPath);
10
+ let buffer = "";
11
+ let settled = false;
12
+
13
+ function finish(fn, value) {
14
+ if (settled) return;
15
+ settled = true;
16
+ clearTimeout(timeout);
17
+ socket.end();
18
+ fn(value);
19
+ }
20
+
21
+ const timeout = setTimeout(() => {
22
+ socket.destroy();
23
+ finish(reject, new Error("Arisa IPC request timed out"));
24
+ }, timeoutMs);
25
+ timeout.unref?.();
26
+
27
+ socket.setEncoding("utf8");
28
+ socket.once("connect", () => {
29
+ socket.write(`${JSON.stringify(request)}\n`);
30
+ });
31
+ socket.on("data", (chunk) => {
32
+ buffer += chunk;
33
+ const newlineIndex = buffer.indexOf("\n");
34
+ if (newlineIndex === -1) return;
35
+ const line = buffer.slice(0, newlineIndex);
36
+ try {
37
+ const response = JSON.parse(line);
38
+ if (!response.ok) {
39
+ finish(reject, new Error(response.error || "Arisa IPC request failed"));
40
+ return;
41
+ }
42
+ finish(resolve, response.result);
43
+ } catch (error) {
44
+ finish(reject, error);
45
+ }
46
+ });
47
+ socket.once("error", (error) => {
48
+ finish(reject, error);
49
+ });
50
+ socket.once("close", () => {
51
+ finish(reject, new Error("Arisa IPC connection closed before response"));
52
+ });
53
+ });
54
+ }
55
+
56
+ export function createArisaClient({ toolName, chatId = null, socketPath = process.env.ARISA_IPC_SOCKET || arisaIpcSocketFile } = {}) {
57
+ if (typeof toolName !== "string" || !toolName.trim()) {
58
+ throw new Error("toolName is required");
59
+ }
60
+
61
+ const call = (method, params = {}) => requestIpc({
62
+ socketPath,
63
+ request: {
64
+ id: crypto.randomUUID(),
65
+ method,
66
+ toolName,
67
+ chatId,
68
+ params
69
+ }
70
+ });
71
+
72
+ return {
73
+ artifacts: {
74
+ createText: (params) => call("artifacts.createText", params),
75
+ listRecent: (params) => call("artifacts.listRecent", params),
76
+ get: (params) => call("artifacts.get", params)
77
+ },
78
+ tasks: {
79
+ add: (params) => call("tasks.add", params),
80
+ list: (params) => call("tasks.list", params),
81
+ cancel: (params) => call("tasks.cancel", params)
82
+ },
83
+ agent: {
84
+ enqueueEvent: (params) => call("agent.enqueueEvent", params)
85
+ },
86
+ paths: {
87
+ getChatToolStateDir: () => call("paths.getChatToolStateDir"),
88
+ getToolStateDir: () => call("paths.getToolStateDir"),
89
+ getChatToolTmpDir: () => call("paths.getChatToolTmpDir"),
90
+ getToolTmpDir: () => call("paths.getToolTmpDir"),
91
+ getChatArtifactsDir: () => call("paths.getChatArtifactsDir")
92
+ }
93
+ };
94
+ }
@@ -1,14 +1,13 @@
1
1
  import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
5
- import { validateToolWebRoutes } from "../../runtime/web/route-validation.js";
4
+ import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
6
5
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
7
6
  import { normalizeToolResult } from "./tool-result.js";
8
7
  import { SkillRegistry } from "../skills/skill-registry.js";
9
8
 
10
9
  function toolEnv() {
11
- return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir };
10
+ return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir, ARISA_IPC_SOCKET: arisaIpcSocketFile };
12
11
  }
13
12
 
14
13
  function runProcess(command, args, options = {}) {
@@ -26,14 +25,11 @@ export class ToolRegistry {
26
25
  constructor({ logger } = {}) {
27
26
  this.logger = logger;
28
27
  this.tools = new Map();
29
- this.webRoutes = [];
30
28
  this.skillRegistry = new SkillRegistry();
31
29
  }
32
30
 
33
31
  async load() {
34
32
  this.tools.clear();
35
- this.webRoutes = [];
36
- const claimedWebPaths = new Map();
37
33
 
38
34
  let entries = [];
39
35
  try {
@@ -54,7 +50,7 @@ export class ToolRegistry {
54
50
  const defaults = parseConfigModule(configSource);
55
51
  const config = await loadToolConfig(manifest.name, defaults);
56
52
  const skillHints = this.skillRegistry.normalizeHints(manifest);
57
- const tool = {
53
+ this.tools.set(manifest.name, {
58
54
  ...manifest,
59
55
  skillHints,
60
56
  dir: toolDir,
@@ -63,13 +59,7 @@ export class ToolRegistry {
63
59
  configPath: getToolConfigPath(manifest.name),
64
60
  defaults,
65
61
  config
66
- };
67
- this.tools.set(manifest.name, tool);
68
- const { routes, errors } = validateToolWebRoutes(manifest, toolDir, claimedWebPaths);
69
- this.webRoutes.push(...routes);
70
- for (const error of errors) {
71
- this.logger?.log("web", `skipping route for ${manifest.name}: ${error.error}`);
72
- }
62
+ });
73
63
  } catch {
74
64
  // ignore invalid tool dirs in v1
75
65
  }
@@ -89,13 +79,6 @@ export class ToolRegistry {
89
79
  }));
90
80
  }
91
81
 
92
- listWebRoutes() {
93
- return this.webRoutes.map((route) => ({
94
- ...route,
95
- methods: [...route.methods]
96
- }));
97
- }
98
-
99
82
  get(name) {
100
83
  return this.tools.get(name) || null;
101
84
  }
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ import { createLogger } from "./runtime/logger.js";
8
8
  import { getServiceStatus, registerServiceProcess, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
9
9
  import { flushArisaHome } from "./runtime/flush.js";
10
10
  import { arisaPackageDir } from "./runtime/paths.js";
11
+ import { parseProcessIds } from "./runtime/process-ids.js";
11
12
 
12
13
  process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
13
14
 
@@ -49,11 +50,11 @@ async function runCommand(commandName, args = []) {
49
50
  async function getListeningPids(port) {
50
51
  const lsofOutput = await runCommand("lsof", ["-tiTCP", `-sTCP:LISTEN`, `-iTCP:${port}`]);
51
52
  if (lsofOutput) {
52
- return lsofOutput.split(/\s+/).map(Number).filter(Number.isFinite);
53
+ return parseProcessIds(lsofOutput);
53
54
  }
54
55
 
55
56
  const fuserOutput = await runCommand("fuser", ["-n", "tcp", String(port)]);
56
- return fuserOutput.split(/\s+/).map(Number).filter(Number.isFinite);
57
+ return parseProcessIds(fuserOutput);
57
58
  }
58
59
 
59
60
  async function getProcessCommand(pid) {
@@ -0,0 +1,123 @@
1
+ import {
2
+ getChatArtifactsDir,
3
+ getChatToolStateDir,
4
+ getChatToolTmpDir,
5
+ getToolStateDir,
6
+ getToolTmpDir
7
+ } from "./paths.js";
8
+
9
+ function requireToolName(toolName) {
10
+ if (typeof toolName !== "string" || !toolName.trim()) {
11
+ throw new Error("toolName is required");
12
+ }
13
+ return toolName.trim();
14
+ }
15
+
16
+ function requireChatId(chatId, method) {
17
+ if (chatId == null || chatId === "") {
18
+ throw new Error(`${method} requires chatId`);
19
+ }
20
+ return chatId;
21
+ }
22
+
23
+ function requireString(value, fieldName) {
24
+ if (typeof value !== "string" || !value.trim()) {
25
+ throw new Error(`${fieldName} is required`);
26
+ }
27
+ return value;
28
+ }
29
+
30
+ function normalizeLimit(limit) {
31
+ const value = Number(limit);
32
+ if (!Number.isInteger(value) || value <= 0) return 20;
33
+ return Math.min(value, 100);
34
+ }
35
+
36
+ export function createArisaCapabilities({ artifactStore, taskStore } = {}) {
37
+ async function dispatch({ method, toolName, chatId = null, params = {} } = {}) {
38
+ const scopedToolName = requireToolName(toolName);
39
+
40
+ if (method === "artifacts.createText") {
41
+ const scopedChatId = requireChatId(chatId, method);
42
+ return artifactStore.forChat(scopedChatId).createText({
43
+ text: requireString(params.text, "text"),
44
+ mimeType: params.mimeType || "text/plain",
45
+ source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId },
46
+ metadata: params.metadata || {}
47
+ });
48
+ }
49
+
50
+ if (method === "artifacts.listRecent") {
51
+ const scopedChatId = requireChatId(chatId, method);
52
+ return artifactStore.forChat(scopedChatId).listRecent(normalizeLimit(params.limit));
53
+ }
54
+
55
+ if (method === "artifacts.get") {
56
+ const scopedChatId = requireChatId(chatId, method);
57
+ return artifactStore.forChat(scopedChatId).get(requireString(params.artifactId, "artifactId"));
58
+ }
59
+
60
+ if (method === "tasks.add") {
61
+ const scopedChatId = requireChatId(chatId, method);
62
+ return taskStore.add(params.task || {}, {
63
+ payload: { chatId: scopedChatId },
64
+ source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId }
65
+ });
66
+ }
67
+
68
+ if (method === "tasks.list") {
69
+ const scopedChatId = requireChatId(chatId, method);
70
+ return taskStore.list({
71
+ chatId: scopedChatId,
72
+ status: params.status || undefined,
73
+ kind: params.kind || undefined
74
+ });
75
+ }
76
+
77
+ if (method === "tasks.cancel") {
78
+ const scopedChatId = requireChatId(chatId, method);
79
+ const taskId = requireString(params.taskId, "taskId");
80
+ const task = await taskStore.get(taskId);
81
+ if (!task) return null;
82
+ if (String(task.payload?.chatId) !== String(scopedChatId)) {
83
+ throw new Error("task does not belong to chatId");
84
+ }
85
+ return taskStore.cancel(taskId);
86
+ }
87
+
88
+ if (method === "agent.enqueueEvent") {
89
+ const scopedChatId = requireChatId(chatId, method);
90
+ return taskStore.add({
91
+ kind: "agent_event",
92
+ payload: { prompt: requireString(params.prompt, "prompt") }
93
+ }, {
94
+ payload: { chatId: scopedChatId },
95
+ source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId }
96
+ });
97
+ }
98
+
99
+ if (method === "paths.getChatToolStateDir") {
100
+ return getChatToolStateDir(requireChatId(chatId, method), scopedToolName);
101
+ }
102
+
103
+ if (method === "paths.getToolStateDir") {
104
+ return getToolStateDir(scopedToolName);
105
+ }
106
+
107
+ if (method === "paths.getChatToolTmpDir") {
108
+ return getChatToolTmpDir(requireChatId(chatId, method), scopedToolName);
109
+ }
110
+
111
+ if (method === "paths.getToolTmpDir") {
112
+ return getToolTmpDir(scopedToolName);
113
+ }
114
+
115
+ if (method === "paths.getChatArtifactsDir") {
116
+ return getChatArtifactsDir(requireChatId(chatId, method));
117
+ }
118
+
119
+ throw new Error(`unknown IPC method: ${method}`);
120
+ }
121
+
122
+ return { dispatch };
123
+ }
@@ -1,4 +1,3 @@
1
- import crypto from "node:crypto";
2
1
  import { loadConfig, saveConfig, updateConfig } from "../core/config/config-store.js";
3
2
  import { ArtifactStore } from "../core/artifacts/artifact-store.js";
4
3
  import { ToolRegistry } from "../core/tools/tool-registry.js";
@@ -7,14 +6,8 @@ import { AgentManager } from "../core/agent/agent-manager.js";
7
6
  import { getErrorMessage, getPiAuthIssue } from "../core/agent/auth-flow.js";
8
7
  import { createTelegramBot } from "../transport/telegram/bot.js";
9
8
  import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
10
- import { createWebRouter } from "./web/web-router.js";
11
- import {
12
- getChatArtifactsDir,
13
- getChatToolStateDir,
14
- getChatToolTmpDir,
15
- getToolStateDir,
16
- getToolTmpDir
17
- } from "./paths.js";
9
+ import { createArisaCapabilities } from "./arisa-capabilities.js";
10
+ import { createIpcServer } from "./ipc/ipc-server.js";
18
11
 
19
12
  function normalizeString(value) {
20
13
  const text = String(value ?? "").trim();
@@ -53,29 +46,6 @@ function applyRuntimeOverrides(config, runtimeOverrides) {
53
46
  };
54
47
  }
55
48
 
56
- async function ensureWebToken(config, logger) {
57
- if (config.web?.token) return config.web.token;
58
-
59
- const generatedToken = crypto.randomBytes(24).toString("hex");
60
- const persisted = await updateConfig((storedConfig) => {
61
- storedConfig.web ||= {};
62
- storedConfig.web.token ||= generatedToken;
63
- });
64
- config.web = { ...(config.web || {}), token: persisted.web.token };
65
- logger?.log("web", "generated shared web route token");
66
- return config.web.token;
67
- }
68
-
69
- function createToolWebPaths() {
70
- return {
71
- getChatArtifactsDir,
72
- getChatToolStateDir,
73
- getChatToolTmpDir,
74
- getToolStateDir,
75
- getToolTmpDir
76
- };
77
- }
78
-
79
49
  export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpRequestHandler } = {}) {
80
50
  logger?.log("app", "loading config");
81
51
  const persistedConfig = await loadConfig();
@@ -92,33 +62,9 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
92
62
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
93
63
 
94
64
  const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
95
- await ensureWebToken(config, logger);
96
- const webRouter = createWebRouter({
97
- getRoutes: () => toolRegistry.listWebRoutes(),
98
- getToken: () => config.web?.token || "",
99
- logger,
100
- buildContext: ({ toolName, chatId }) => ({
101
- toolName,
102
- chatId,
103
- logger,
104
- toolRegistry,
105
- artifactStore,
106
- taskStore,
107
- agentManager,
108
- paths: createToolWebPaths()
109
- })
110
- });
111
- webRouter.registerCoreRoute({
112
- method: "GET",
113
- path: "/health",
114
- handler: (_req, res) => {
115
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
116
- res.end("ok\n");
117
- }
118
- });
119
- setHttpRequestHandler?.(webRouter.dispatch);
120
-
121
- const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, webRouter: setHttpRequestHandler ? webRouter : null });
65
+ const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore });
66
+ const ipcServer = createIpcServer({ capabilities: arisaCapabilities, logger });
67
+ const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler });
122
68
 
123
69
  return {
124
70
  async start() {
@@ -135,12 +81,18 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
135
81
  logger?.error("app", `Pi auth validation failed; starting Telegram in auth recovery mode: ${getErrorMessage(error)}`);
136
82
  await bot.notifyPiAuthIssue?.(error);
137
83
  }
138
- await toolProcessSupervisor.start();
139
- logger?.log("app", "starting Telegram bot");
84
+ let ipcStarted = false;
85
+ let supervisorStarted = false;
140
86
  try {
87
+ await ipcServer.start();
88
+ ipcStarted = true;
89
+ await toolProcessSupervisor.start();
90
+ supervisorStarted = true;
91
+ logger?.log("app", "starting Telegram bot");
141
92
  await bot.start({ skipAgentStartupPrompts });
142
93
  } catch (error) {
143
- await toolProcessSupervisor.stop();
94
+ if (supervisorStarted) await toolProcessSupervisor.stop();
95
+ if (ipcStarted) await ipcServer.stop();
144
96
  throw error;
145
97
  }
146
98
  },
@@ -148,6 +100,7 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
148
100
  async stop() {
149
101
  await bot.stop?.();
150
102
  await toolProcessSupervisor.stop();
103
+ await ipcServer.stop();
151
104
  }
152
105
  };
153
106
  }
@@ -0,0 +1,109 @@
1
+ import net from "node:net";
2
+ import path from "node:path";
3
+ import { chmod, mkdir, unlink } from "node:fs/promises";
4
+ import { arisaIpcSocketFile } from "../paths.js";
5
+
6
+ function writeResponse(socket, response) {
7
+ socket.write(`${JSON.stringify(response)}\n`);
8
+ }
9
+
10
+ function isStaleSocketError(error) {
11
+ return ["ENOENT", "ECONNREFUSED"].includes(error?.code);
12
+ }
13
+
14
+ async function hasLiveSocket(socketPath) {
15
+ return new Promise((resolve, reject) => {
16
+ const client = net.createConnection(socketPath);
17
+ client.once("connect", () => {
18
+ client.end();
19
+ resolve(true);
20
+ });
21
+ client.once("error", (error) => {
22
+ if (isStaleSocketError(error)) {
23
+ resolve(false);
24
+ return;
25
+ }
26
+ reject(error);
27
+ });
28
+ });
29
+ }
30
+
31
+ export function createIpcServer({ capabilities, socketPath = arisaIpcSocketFile, logger } = {}) {
32
+ let server = null;
33
+
34
+ async function handleLine(socket, line) {
35
+ let request;
36
+ try {
37
+ request = JSON.parse(line);
38
+ } catch {
39
+ writeResponse(socket, { id: null, ok: false, error: "invalid JSON request" });
40
+ return;
41
+ }
42
+
43
+ try {
44
+ const result = await capabilities.dispatch(request);
45
+ writeResponse(socket, { id: request.id ?? null, ok: true, result });
46
+ } catch (error) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ logger?.log?.("ipc", `request failed: ${message}`);
49
+ writeResponse(socket, { id: request.id ?? null, ok: false, error: message });
50
+ }
51
+ }
52
+
53
+ async function start() {
54
+ if (server) return { socketPath };
55
+ await mkdir(path.dirname(socketPath), { recursive: true });
56
+
57
+ if (await hasLiveSocket(socketPath)) {
58
+ throw new Error(`Arisa IPC socket already in use: ${socketPath}`);
59
+ }
60
+ await unlink(socketPath).catch((error) => {
61
+ if (error?.code !== "ENOENT") throw error;
62
+ });
63
+
64
+ server = net.createServer((socket) => {
65
+ socket.setEncoding("utf8");
66
+ let buffer = "";
67
+ socket.on("data", (chunk) => {
68
+ buffer += chunk;
69
+ let newlineIndex = buffer.indexOf("\n");
70
+ while (newlineIndex !== -1) {
71
+ const line = buffer.slice(0, newlineIndex).trim();
72
+ buffer = buffer.slice(newlineIndex + 1);
73
+ if (line) handleLine(socket, line);
74
+ newlineIndex = buffer.indexOf("\n");
75
+ }
76
+ });
77
+ socket.on("error", (error) => {
78
+ logger?.log?.("ipc", `client socket error: ${error instanceof Error ? error.message : String(error)}`);
79
+ });
80
+ });
81
+
82
+ await new Promise((resolve, reject) => {
83
+ server.once("error", reject);
84
+ server.listen(socketPath, resolve);
85
+ });
86
+ await chmod(socketPath, 0o600).catch(() => {});
87
+ logger?.log?.("ipc", `listening on ${socketPath}`);
88
+ return { socketPath };
89
+ }
90
+
91
+ async function stop() {
92
+ if (!server) return;
93
+ const closingServer = server;
94
+ server = null;
95
+ await new Promise((resolve) => closingServer.close(resolve));
96
+ await unlink(socketPath).catch((error) => {
97
+ if (error?.code !== "ENOENT") throw error;
98
+ });
99
+ }
100
+
101
+ return {
102
+ start,
103
+ stop,
104
+ address: () => server?.address() || null,
105
+ get socketPath() {
106
+ return socketPath;
107
+ }
108
+ };
109
+ }
@@ -9,6 +9,7 @@ export const stateDir = path.join(arisaHomeDir, "state");
9
9
  export const configFile = path.join(stateDir, "config.json");
10
10
  export const servicePidFile = path.join(stateDir, "arisa.pid");
11
11
  export const serviceLogFile = path.join(stateDir, "arisa.log");
12
+ export const arisaIpcSocketFile = path.join(stateDir, "arisa.sock");
12
13
  export const tasksFile = path.join(stateDir, "tasks.json");
13
14
  export const toolsDir = path.join(arisaHomeDir, "tools");
14
15
  export const chatsDir = path.join(arisaHomeDir, "chats");
@@ -0,0 +1,9 @@
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
+ }