arisa 4.0.16 → 4.0.20
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 +19 -22
- package/README.md +2 -26
- package/package.json +7 -8
- package/src/core/tools/ipc-client.js +94 -0
- package/src/core/tools/tool-registry.js +4 -21
- package/src/index.js +10 -146
- package/src/runtime/arisa-capabilities.js +123 -0
- package/src/runtime/bootstrap.js +3 -165
- package/src/runtime/create-app.js +16 -63
- package/src/runtime/ipc/ipc-server.js +109 -0
- package/src/runtime/paths.js +1 -0
- package/src/runtime/service-manager.js +2 -2
- package/src/transport/telegram/bot.js +6 -21
- package/test/ipc-server.test.js +154 -0
- package/src/runtime/process-ids.js +0 -9
- package/src/runtime/web/route-validation.js +0 -115
- package/src/runtime/web/web-router.js +0 -167
- package/test/process-ids.test.js +0 -14
- package/test/route-validation.test.js +0 -88
- package/test/web-router.test.js +0 -267
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
- Arisa's HTTP server listens on `ARISA_HTTP_PORT` (default `11970`); bootstrap OAuth pages and tool-provided web routes use this 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,17 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arisa",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.20",
|
|
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
|
-
|
|
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
|
@@ -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 =
|
|
16
|
+
const verbose = !cli.flags.silent;
|
|
20
17
|
const serviceRunner = Boolean(cli.flags["service-runner"]);
|
|
21
|
-
const
|
|
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
|
|
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(
|
|
235
|
-
const app = await createApp({ logger, runtimeOverrides
|
|
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
|
-
|
|
248
|
-
await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
|
|
114
|
+
await bootstrapIfNeeded({ force: forceBootstrap });
|
|
249
115
|
try {
|
|
250
|
-
await startRuntimeApp(
|
|
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
|
|
264
|
-
await startRuntimeApp(
|
|
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
|
-
|
|
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}).`);
|
|
@@ -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
|
+
}
|