arisa 5.1.2 → 5.1.8

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.
Files changed (39) hide show
  1. package/AGENTS.md +12 -4
  2. package/ARISA-MASTER-SLAVE-SPEC.md +844 -0
  3. package/README.md +25 -0
  4. package/package.json +1 -1
  5. package/src/core/agent/agent-manager.js +55 -12
  6. package/src/core/config/config-defaults.js +3 -1
  7. package/src/core/tools/daemon-processes.js +48 -6
  8. package/src/core/tools/daemon-runtime.js +370 -138
  9. package/src/core/tools/ipc-client.js +3 -0
  10. package/src/core/tools/official-tool-catalog.js +32 -0
  11. package/src/core/tools/official-tool-installer.js +183 -0
  12. package/src/core/tools/tool-registry.js +203 -18
  13. package/src/core/tools/tool-resource-note-store.js +78 -0
  14. package/src/index.js +25 -2
  15. package/src/official-tools.lock.json +40 -0
  16. package/src/runtime/arisa-capabilities.js +43 -3
  17. package/src/runtime/create-app.js +9 -0
  18. package/src/runtime/create-headless-app.js +77 -0
  19. package/src/runtime/doctor.js +27 -2
  20. package/src/runtime/headless-tool-executor.js +45 -0
  21. package/src/runtime/paths.js +16 -4
  22. package/src/runtime/secure-request-file.js +21 -0
  23. package/src/runtime/slave-bootstrap-url.js +51 -0
  24. package/src/runtime/slave-cli.js +267 -0
  25. package/src/runtime/slave-service.js +225 -0
  26. package/src/runtime/tool-usage-report.js +11 -3
  27. package/src/transport/telegram/bot.js +37 -7
  28. package/test/capabilities-security.test.js +29 -0
  29. package/test/daemon-catalog-conformance.test.js +3 -1
  30. package/test/daemon-runtime.test.js +58 -2
  31. package/test/official-tool-installer.test.js +107 -0
  32. package/test/paths.test.js +6 -12
  33. package/test/slave-cli.test.js +282 -0
  34. package/test/telegram-text-artifact.test.js +24 -1
  35. package/test/tool-capability-search.test.js +55 -0
  36. package/test/tool-registry-run.test.js +70 -1
  37. package/test/tool-resource-note.test.js +50 -0
  38. package/test/tool-usage.test.js +10 -5
  39. package/test-fixtures/fake-daemon.js +12 -1
@@ -0,0 +1,183 @@
1
+ import crypto from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import {
4
+ access,
5
+ cp,
6
+ lstat,
7
+ mkdir,
8
+ mkdtemp,
9
+ readFile,
10
+ readdir,
11
+ rename,
12
+ rm
13
+ } from "node:fs/promises";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { getToolDir } from "../../runtime/paths.js";
17
+
18
+ const bundledLockFile = new URL("../../official-tools.lock.json", import.meta.url);
19
+
20
+ const TOOL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
21
+ const COMMIT_PATTERN = /^[a-f0-9]{40}$/;
22
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/;
23
+
24
+ function exists(target) {
25
+ return access(target).then(() => true, () => false);
26
+ }
27
+
28
+ function assertRelativeFilePath(file) {
29
+ if (typeof file !== "string" || !file || file.includes("\\")) {
30
+ throw new Error(`Invalid official tool file path: ${file || "empty"}`);
31
+ }
32
+ const normalized = path.posix.normalize(file);
33
+ if (normalized !== file || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
34
+ throw new Error(`Invalid official tool file path: ${file}`);
35
+ }
36
+ }
37
+
38
+ export function validateOfficialToolLock(lock, toolName) {
39
+ if (!TOOL_NAME_PATTERN.test(String(toolName || ""))) {
40
+ throw new Error(`Invalid official tool name: ${toolName || "empty"}`);
41
+ }
42
+ if (lock?.version !== 1) throw new Error("Unsupported official tool lock version");
43
+ if (typeof lock.repository !== "string" || !lock.repository.startsWith("https://")) {
44
+ throw new Error("Official tool lock requires an HTTPS repository");
45
+ }
46
+ if (!COMMIT_PATTERN.test(String(lock.commit || ""))) {
47
+ throw new Error("Official tool lock requires an immutable 40-character commit");
48
+ }
49
+ const files = lock.tools?.[toolName]?.files;
50
+ if (!files || typeof files !== "object" || Array.isArray(files) || !Object.keys(files).length) {
51
+ throw new Error(`Official tool lock has no files for ${toolName}`);
52
+ }
53
+ for (const [file, digest] of Object.entries(files)) {
54
+ assertRelativeFilePath(file);
55
+ if (!SHA256_PATTERN.test(String(digest || ""))) {
56
+ throw new Error(`Invalid SHA-256 digest for ${toolName}/${file}`);
57
+ }
58
+ }
59
+ return { repository: lock.repository, commit: lock.commit, files };
60
+ }
61
+
62
+ async function walkFiles(root, relative = "") {
63
+ const directory = path.join(root, relative);
64
+ const entries = await readdir(directory, { withFileTypes: true });
65
+ const files = [];
66
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
67
+ const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
68
+ const childPath = path.join(root, ...childRelative.split("/"));
69
+ const metadata = await lstat(childPath);
70
+ if (metadata.isSymbolicLink()) {
71
+ throw new Error(`Official tool contains a symbolic link: ${childRelative}`);
72
+ }
73
+ if (metadata.isDirectory()) {
74
+ files.push(...await walkFiles(root, childRelative));
75
+ continue;
76
+ }
77
+ if (!metadata.isFile()) {
78
+ throw new Error(`Official tool contains an unsupported entry: ${childRelative}`);
79
+ }
80
+ files.push(childRelative);
81
+ }
82
+ return files;
83
+ }
84
+
85
+ async function sha256(file) {
86
+ return crypto.createHash("sha256").update(await readFile(file)).digest("hex");
87
+ }
88
+
89
+ export async function verifyOfficialToolTree(toolDir, expectedFiles) {
90
+ const actualFiles = (await walkFiles(toolDir)).sort();
91
+ const lockedFiles = Object.keys(expectedFiles).sort();
92
+ if (actualFiles.length !== lockedFiles.length || actualFiles.some((file, index) => file !== lockedFiles[index])) {
93
+ const missing = lockedFiles.filter((file) => !actualFiles.includes(file));
94
+ const unexpected = actualFiles.filter((file) => !lockedFiles.includes(file));
95
+ throw new Error(`Official tool file set mismatch; missing=${missing.join(",") || "none"}; unexpected=${unexpected.join(",") || "none"}`);
96
+ }
97
+ for (const file of lockedFiles) {
98
+ const digest = await sha256(path.join(toolDir, ...file.split("/")));
99
+ if (!crypto.timingSafeEqual(Buffer.from(digest, "hex"), Buffer.from(expectedFiles[file], "hex"))) {
100
+ throw new Error(`Official tool integrity check failed: ${file}`);
101
+ }
102
+ }
103
+ return { files: lockedFiles.length };
104
+ }
105
+
106
+ function runCommand(command, args, { cwd, timeoutMs = 180_000 } = {}) {
107
+ return new Promise((resolve, reject) => {
108
+ const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
109
+ let stdout = "";
110
+ let stderr = "";
111
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
112
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
113
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
114
+ child.once("error", (error) => {
115
+ clearTimeout(timer);
116
+ reject(error);
117
+ });
118
+ child.once("close", (code, signal) => {
119
+ clearTimeout(timer);
120
+ if (code === 0) resolve({ stdout, stderr });
121
+ else reject(new Error(`${command} failed (${signal || code}): ${(stderr || stdout).trim().slice(-2000)}`));
122
+ });
123
+ });
124
+ }
125
+
126
+ async function checkoutRepository({ repository, commit, checkoutDir }) {
127
+ await mkdir(checkoutDir, { recursive: true });
128
+ await runCommand("git", ["init", "--quiet"], { cwd: checkoutDir });
129
+ await runCommand("git", ["remote", "add", "origin", repository], { cwd: checkoutDir });
130
+ await runCommand("git", ["fetch", "--quiet", "--depth", "1", "origin", commit], { cwd: checkoutDir });
131
+ await runCommand("git", ["checkout", "--quiet", "--detach", "FETCH_HEAD"], { cwd: checkoutDir });
132
+ const resolved = (await runCommand("git", ["rev-parse", "HEAD"], { cwd: checkoutDir })).stdout.trim();
133
+ if (resolved !== commit) throw new Error(`Official tool checkout resolved ${resolved}, expected ${commit}`);
134
+ }
135
+
136
+ async function validateEntrypoint(toolDir, toolName) {
137
+ const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
138
+ if (manifest.name !== toolName) {
139
+ throw new Error(`Official tool manifest mismatch: expected ${toolName}, got ${manifest.name || "missing"}`);
140
+ }
141
+ const entry = manifest.entry || "index.js";
142
+ const entryPath = path.join(toolDir, entry);
143
+ if (!(await exists(entryPath))) throw new Error(`Official tool entry does not exist: ${entry}`);
144
+ await runCommand(process.execPath, ["--check", entry], { cwd: toolDir, timeoutMs: 30_000 });
145
+ await runCommand(process.execPath, [entry, "--help"], { cwd: toolDir, timeoutMs: 30_000 });
146
+ }
147
+
148
+ export async function installLockedOfficialTool({
149
+ toolName,
150
+ lock,
151
+ destination,
152
+ scratchRoot = os.tmpdir(),
153
+ checkout = checkoutRepository,
154
+ validate = validateEntrypoint
155
+ }) {
156
+ const locked = validateOfficialToolLock(lock, toolName);
157
+ if (await exists(destination)) {
158
+ throw new Error(`Refusing to overwrite installed tool: ${destination}`);
159
+ }
160
+ const scratch = await mkdtemp(path.join(scratchRoot, `arisa-${toolName}-`));
161
+ const checkoutDir = path.join(scratch, "repository");
162
+ const stageDir = path.join(scratch, "stage");
163
+ try {
164
+ await checkout({ repository: locked.repository, commit: locked.commit, checkoutDir });
165
+ const sourceDir = path.join(checkoutDir, "tools", toolName);
166
+ await verifyOfficialToolTree(sourceDir, locked.files);
167
+ await cp(sourceDir, stageDir, { recursive: true, errorOnExist: true, force: false });
168
+ await validate(stageDir, toolName);
169
+ await mkdir(path.dirname(destination), { recursive: true });
170
+ await rename(stageDir, destination);
171
+ return { toolName, destination, commit: locked.commit, files: Object.keys(locked.files).length };
172
+ } finally {
173
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
174
+ }
175
+ }
176
+
177
+ export async function installBundledOfficialTool(toolName, {
178
+ lockFile = bundledLockFile,
179
+ install = installLockedOfficialTool
180
+ } = {}) {
181
+ const lock = JSON.parse(await readFile(lockFile, "utf8"));
182
+ return install({ toolName, lock, destination: getToolDir(toolName) });
183
+ }
@@ -6,6 +6,8 @@ import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolTmpDir,
6
6
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
7
7
  import { normalizeToolResult } from "./tool-result.js";
8
8
  import { readDaemonDiagnostic } from "./daemon-processes.js";
9
+ import { createDaemonRuntime, DAEMON_EVENT_TYPES, DAEMON_PROTOCOL_VERSION } from "./daemon-runtime.js";
10
+ import { daemonConfigDefaults } from "../config/config-defaults.js";
9
11
  import { SkillRegistry } from "../skills/skill-registry.js";
10
12
  import { ToolUsageStore } from "./tool-usage-store.js";
11
13
 
@@ -24,6 +26,129 @@ function runProcess(command, args, options = {}) {
24
26
  });
25
27
  }
26
28
 
29
+ function requirementNames(requirements) {
30
+ if (Array.isArray(requirements)) {
31
+ return requirements.map((item) => typeof item === "string" ? item : item?.name).filter(Boolean);
32
+ }
33
+ if (requirements && typeof requirements === "object") return Object.keys(requirements);
34
+ return [];
35
+ }
36
+
37
+ export function createToolOutputParser(name, { onEvent, maxFrameBytes = 1_048_576 } = {}) {
38
+ let buffer = "";
39
+ let mode = "unknown";
40
+ let rawOutput = "";
41
+ let terminalResult = null;
42
+ let activeJobId = null;
43
+ let sequence = 0;
44
+ let terminalSeen = false;
45
+
46
+ async function parseEvent(line) {
47
+ let event;
48
+ try {
49
+ event = JSON.parse(line);
50
+ } catch {
51
+ throw new Error(`Invalid NDJSON from ${name}`);
52
+ }
53
+ if (event?.version !== DAEMON_PROTOCOL_VERSION || !DAEMON_EVENT_TYPES.includes(event?.type)) {
54
+ throw new Error(`Invalid versioned tool event from ${name}`);
55
+ }
56
+ if (typeof event.jobId !== "string" || !event.jobId) throw new Error(`Tool event from ${name} is missing jobId`);
57
+ if (activeJobId == null) activeJobId = event.jobId;
58
+ if (event.jobId !== activeJobId) throw new Error(`Tool ${name} multiplexed an unexpected jobId`);
59
+ if (!Number.isSafeInteger(event.sequence) || event.sequence !== sequence + 1) {
60
+ throw new Error(`Invalid tool event sequence from ${name}: ${event.sequence}`);
61
+ }
62
+ if (terminalSeen) throw new Error(`Tool ${name} emitted more than one terminal event`);
63
+ sequence = event.sequence;
64
+ terminalSeen = event.type === "completed" || event.type === "failed";
65
+ await onEvent?.(event);
66
+ if (terminalSeen) {
67
+ terminalResult = event.type === "completed"
68
+ ? event.payload?.result ?? event.payload?.output ?? event.payload
69
+ : { ok: false, error: event.payload?.error || `Tool failed: ${name}`, ...(event.payload?.code ? { code: event.payload.code } : {}) };
70
+ }
71
+ }
72
+
73
+ async function consumeLine(line) {
74
+ if (Buffer.byteLength(line, "utf8") > maxFrameBytes) throw new Error(`Tool event from ${name} exceeds ${maxFrameBytes} bytes`);
75
+ if (mode === "unknown") {
76
+ let candidate;
77
+ try {
78
+ candidate = JSON.parse(line);
79
+ } catch {
80
+ mode = "legacy";
81
+ return;
82
+ }
83
+ if (candidate?.version === DAEMON_PROTOCOL_VERSION && DAEMON_EVENT_TYPES.includes(candidate?.type)) {
84
+ mode = "ndjson";
85
+ rawOutput = "";
86
+ return parseEvent(line);
87
+ }
88
+ mode = "legacy";
89
+ return;
90
+ }
91
+ if (mode === "legacy") return;
92
+ return parseEvent(line);
93
+ }
94
+
95
+ return {
96
+ async push(chunk) {
97
+ const text = chunk.toString("utf8");
98
+ if (mode !== "ndjson") rawOutput += text;
99
+ if (mode === "legacy") return;
100
+ buffer += text;
101
+ if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes && !buffer.includes("\n")) {
102
+ throw new Error(`Tool event from ${name} exceeds ${maxFrameBytes} bytes`);
103
+ }
104
+ let newlineIndex = buffer.indexOf("\n");
105
+ while (newlineIndex !== -1) {
106
+ const line = buffer.slice(0, newlineIndex).trim();
107
+ buffer = buffer.slice(newlineIndex + 1);
108
+ if (line) await consumeLine(line);
109
+ newlineIndex = buffer.indexOf("\n");
110
+ }
111
+ },
112
+ async finish() {
113
+ const tail = buffer.trim();
114
+ buffer = "";
115
+ if (tail) await consumeLine(tail);
116
+ if (mode !== "ndjson") return { mode: "legacy", output: rawOutput };
117
+ if (!terminalSeen) throw new Error(`Tool ${name} ended without a terminal event`);
118
+ return { mode: "ndjson", result: terminalResult };
119
+ }
120
+ };
121
+ }
122
+
123
+ async function runToolProcess(command, args, { onEvent, maxFrameBytes, ...options } = {}) {
124
+ const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
125
+ const parser = createToolOutputParser(path.basename(args[0] || command), { onEvent, maxFrameBytes });
126
+ const stderrChunks = [];
127
+ let stderrBytes = 0;
128
+ const stdoutTask = (async () => {
129
+ for await (const chunk of child.stdout) await parser.push(chunk);
130
+ return parser.finish();
131
+ })();
132
+ const stderrTask = (async () => {
133
+ for await (const chunk of child.stderr) {
134
+ if (stderrBytes >= maxFrameBytes) continue;
135
+ const accepted = chunk.subarray(0, maxFrameBytes - stderrBytes);
136
+ stderrChunks.push(accepted);
137
+ stderrBytes += accepted.length;
138
+ }
139
+ return Buffer.concat(stderrChunks).toString("utf8");
140
+ })();
141
+ const exitPromise = new Promise((resolve, reject) => {
142
+ child.once("error", reject);
143
+ child.once("close", resolve);
144
+ });
145
+ child.stdout.resume();
146
+ child.stderr.resume();
147
+ const code = await exitPromise;
148
+ const [parsed, stderr] = await Promise.all([stdoutTask, stderrTask]);
149
+ return { code, parsed, stderr };
150
+ }
151
+
27
152
  function normalizeCategory(category) {
28
153
  if (typeof category !== "string") return null;
29
154
  const trimmed = category.trim();
@@ -38,6 +163,38 @@ function normalizeKeywords(keywords) {
38
163
  .filter(Boolean))];
39
164
  }
40
165
 
166
+ function searchableToolText(tool) {
167
+ return [
168
+ tool.name,
169
+ tool.description,
170
+ tool.category,
171
+ ...(tool.keywords || []),
172
+ ...(tool.input || []),
173
+ ...(tool.output || [])
174
+ ].filter(Boolean).join(" ").toLowerCase();
175
+ }
176
+
177
+ export function rankToolMatches(tools, query) {
178
+ const terms = String(query || "").toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
179
+ if (!terms.length) return [];
180
+ return tools.map((tool) => {
181
+ const name = String(tool.name || "").toLowerCase();
182
+ const category = String(tool.category || "").toLowerCase();
183
+ const keywords = (tool.keywords || []).map((keyword) => String(keyword).toLowerCase());
184
+ const haystack = searchableToolText(tool);
185
+ let score = 0;
186
+ for (const term of terms) {
187
+ if (keywords.includes(term)) score += 12;
188
+ else if (name === term) score += 10;
189
+ else if (name.includes(term)) score += 7;
190
+ else if (category === term) score += 6;
191
+ else if (haystack.includes(term)) score += 2;
192
+ }
193
+ return { tool, score };
194
+ }).filter((match) => match.score > 0)
195
+ .sort((left, right) => right.score - left.score || left.tool.name.localeCompare(right.tool.name));
196
+ }
197
+
41
198
  function formatSemanticMetadata(tool) {
42
199
  return [
43
200
  "Semantic metadata:",
@@ -99,6 +256,9 @@ export class ToolRegistry {
99
256
  list() {
100
257
  return [...this.tools.values()].map((tool) => ({
101
258
  name: tool.name,
259
+ version: typeof tool.version === "string" ? tool.version : null,
260
+ packageDigest: typeof tool.packageDigest === "string" ? tool.packageDigest : null,
261
+ requirements: requirementNames(tool.requirements),
102
262
  description: tool.description,
103
263
  input: tool.input,
104
264
  output: tool.output,
@@ -109,6 +269,10 @@ export class ToolRegistry {
109
269
  }));
110
270
  }
111
271
 
272
+ search(query) {
273
+ return rankToolMatches(this.list(), query).map(({ tool, score }) => ({ ...tool, score }));
274
+ }
275
+
112
276
  async listWithRuntime(chatId = null) {
113
277
  return Promise.all(this.list().map(async (listedTool) => {
114
278
  const daemon = this.get(listedTool.name)?.daemon;
@@ -203,7 +367,7 @@ export class ToolRegistry {
203
367
  .sort((left, right) => left.name.localeCompare(right.name));
204
368
  }
205
369
 
206
- async run({ name, request, chatId = null }) {
370
+ async run({ name, request, chatId = null, onEvent = null }) {
207
371
  const tool = this.get(name);
208
372
  if (!tool) throw new Error(`Tool not found: ${name}`);
209
373
  await this.usageStore.record(chatId, name).catch((error) => {
@@ -215,33 +379,54 @@ export class ToolRegistry {
215
379
  const requestFile = path.join(tmpDir, `.request-${Date.now()}-${randomUUID()}.json`);
216
380
  const skills = await this.resolveSkills(name);
217
381
  const enrichedRequest = { ...request, chatId, skills };
218
- await writeFile(requestFile, `${JSON.stringify(enrichedRequest, null, 2)}\n`, "utf8");
219
- const result = await runProcess("node", [tool.entry, "run", "--request-file", requestFile], {
220
- cwd: tool.dir,
221
- env: toolEnv()
222
- });
223
- await unlink(requestFile).catch(() => {});
224
- await rmdir(tmpDir).catch(() => {});
225
- if (chatId != null) {
226
- await rmdir(path.dirname(tmpDir)).catch(() => {});
227
- await rmdir(path.dirname(path.dirname(tmpDir))).catch(() => {});
228
- }
382
+ let result;
229
383
  try {
230
- const parsed = JSON.parse(result.stdout || result.stderr);
231
- const normalized = normalizeToolResult(name, parsed);
384
+ if (tool.daemon?.protocol === "arisa-daemon-v1") {
385
+ const scope = tool.daemon.scope === "chat"
386
+ ? { type: "chat", chatId }
387
+ : { type: "global" };
388
+ const runtime = createDaemonRuntime({
389
+ toolName: name,
390
+ entryPath: tool.entry,
391
+ scope,
392
+ startupContext: scope.type === "chat" ? { chatId: String(chatId) } : {},
393
+ autoStart: Boolean(tool.daemon.autoStart)
394
+ });
395
+ result = await runtime.submit(enrichedRequest, { onEvent });
396
+ } else {
397
+ await writeFile(requestFile, `${JSON.stringify(enrichedRequest, null, 2)}\n`, "utf8");
398
+ const processResult = await runToolProcess("node", [tool.entry, "run", "--request-file", requestFile], {
399
+ cwd: tool.dir,
400
+ env: toolEnv(),
401
+ onEvent,
402
+ maxFrameBytes: daemonConfigDefaults.ipcFrameBytes
403
+ });
404
+ if (processResult.stderr.trim()) {
405
+ this.logger?.log("tools", `${name} stderr: ${processResult.stderr.trim()}`);
406
+ }
407
+ result = processResult.parsed.mode === "ndjson"
408
+ ? processResult.parsed.result
409
+ : JSON.parse(processResult.parsed.output);
410
+ }
411
+ const normalized = normalizeToolResult(name, result);
232
412
  if (normalized.ok === false) {
233
413
  this.logger?.log("tools", `${name} -> ${normalized.status || "error"}: ${normalized.error || "unknown error"}`);
234
414
  } else {
235
415
  this.logger?.log("tools", `${name} -> ok`);
236
416
  }
237
417
  return normalized;
238
- } catch {
418
+ } catch (error) {
239
419
  return normalizeToolResult(name, {
240
420
  ok: false,
241
- error: `Invalid tool response for ${name}`,
242
- stdout: result.stdout,
243
- stderr: result.stderr
421
+ error: error?.message || `Invalid tool response for ${name}`
244
422
  });
423
+ } finally {
424
+ await unlink(requestFile).catch(() => {});
425
+ await rmdir(tmpDir).catch(() => {});
426
+ if (chatId != null) {
427
+ await rmdir(path.dirname(tmpDir)).catch(() => {});
428
+ await rmdir(path.dirname(path.dirname(tmpDir))).catch(() => {});
429
+ }
245
430
  }
246
431
  }
247
432
  }
@@ -0,0 +1,78 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getChatToolResourceNotesFile } from "../../runtime/paths.js";
4
+
5
+ export const maxToolResourceNoteCharacters = 200;
6
+
7
+ function emptyNotes() {
8
+ return { version: 1, tools: {} };
9
+ }
10
+
11
+ function requiredText(value, name) {
12
+ const text = String(value ?? "").trim();
13
+ if (!text) throw new Error(`${name} is required`);
14
+ return text;
15
+ }
16
+
17
+ async function readNotes(file) {
18
+ try {
19
+ const parsed = JSON.parse(await readFile(file, "utf8"));
20
+ return parsed?.version === 1 && parsed.tools && typeof parsed.tools === "object"
21
+ ? parsed
22
+ : emptyNotes();
23
+ } catch (error) {
24
+ if (error?.code === "ENOENT") return emptyNotes();
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ async function writeNotes(file, notes) {
30
+ await mkdir(path.dirname(file), { recursive: true });
31
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
32
+ await writeFile(temporary, `${JSON.stringify(notes, null, 2)}\n`, "utf8");
33
+ await rename(temporary, file);
34
+ }
35
+
36
+ export class ToolResourceNoteStore {
37
+ constructor({ resolveFile = getChatToolResourceNotesFile } = {}) {
38
+ this.resolveFile = resolveFile;
39
+ this.queues = new Map();
40
+ }
41
+
42
+ async get(chatId, toolName, resourceId) {
43
+ if (chatId == null || !resourceId) return "";
44
+ await (this.queues.get(String(chatId)) || Promise.resolve()).catch(() => {});
45
+ const notes = await readNotes(this.resolveFile(chatId));
46
+ return notes.tools?.[String(toolName)]?.[String(resourceId)]?.note || "";
47
+ }
48
+
49
+ async set(chatId, toolName, resourceId, note) {
50
+ const scopedToolName = requiredText(toolName, "toolName");
51
+ const scopedResourceId = requiredText(resourceId, "resourceId");
52
+ const scopedNote = String(note ?? "").trim();
53
+ if ([...scopedNote].length > maxToolResourceNoteCharacters) {
54
+ throw new Error(`note must be at most ${maxToolResourceNoteCharacters} characters`);
55
+ }
56
+ const key = String(chatId);
57
+ const previous = this.queues.get(key) || Promise.resolve();
58
+ const current = previous.catch(() => {}).then(async () => {
59
+ const file = this.resolveFile(chatId);
60
+ const notes = await readNotes(file);
61
+ notes.tools[scopedToolName] ||= {};
62
+ if (scopedNote) {
63
+ notes.tools[scopedToolName][scopedResourceId] = { note: scopedNote };
64
+ } else {
65
+ delete notes.tools[scopedToolName][scopedResourceId];
66
+ if (!Object.keys(notes.tools[scopedToolName]).length) delete notes.tools[scopedToolName];
67
+ }
68
+ await writeNotes(file, notes);
69
+ });
70
+ this.queues.set(key, current);
71
+ try {
72
+ await current;
73
+ } finally {
74
+ if (this.queues.get(key) === current) this.queues.delete(key);
75
+ }
76
+ return { ok: true, toolName: scopedToolName, resourceId: scopedResourceId, note: scopedNote };
77
+ }
78
+ }
package/src/index.js CHANGED
@@ -4,10 +4,12 @@ import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
4
4
  import { applyRuntimeOverrides, createApp } from "./runtime/create-app.js";
5
5
  import { loadConfig } from "./core/config/config-store.js";
6
6
  import { createLogger } from "./runtime/logger.js";
7
- import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
7
+ import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, serviceEntryFile, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
8
8
  import { flushArisaHome } from "./runtime/flush.js";
9
9
  import { readPackageVersion, showServiceLogs } from "./runtime/log-viewer.js";
10
10
  import { arisaPackageDir } from "./runtime/paths.js";
11
+ import { runSlaveCli } from "./runtime/slave-cli.js";
12
+ import { unregisterSlaveServiceProcess } from "./runtime/slave-service.js";
11
13
 
12
14
  process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
13
15
 
@@ -17,6 +19,8 @@ const command = cli.positionals[0] || "run";
17
19
  const forceBootstrap = Boolean(cli.flags.bootstrap);
18
20
  const verbose = !cli.flags.silent;
19
21
  const serviceRunner = Boolean(cli.flags["service-runner"]);
22
+ const slaveCommand = command === "slave";
23
+ const slaveServiceRunner = slaveCommand && serviceRunner;
20
24
  const runtimeOverrides = toNestedOverrides(cli.nestedFlags);
21
25
  const logger = createLogger({ verbose });
22
26
  let activeApp = null;
@@ -99,7 +103,10 @@ async function shutdown(exitCode = 0) {
99
103
  logger.error("app", `shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
100
104
  exitCode = exitCode || 1;
101
105
  }
102
- if (serviceRunner) {
106
+ if (slaveServiceRunner) {
107
+ const slavePaths = activeApp?.slavePaths;
108
+ if (slavePaths) await unregisterSlaveServiceProcess(slavePaths);
109
+ } else if (serviceRunner) {
103
110
  await unregisterServiceProcess();
104
111
  }
105
112
  process.exit(exitCode);
@@ -208,6 +215,22 @@ async function runForeground() {
208
215
  }
209
216
 
210
217
  async function main() {
218
+ if (slaveCommand) {
219
+ const result = await runSlaveCli({
220
+ positionals: cli.positionals.slice(1),
221
+ flags: cli.flags,
222
+ logger,
223
+ entryFile: serviceEntryFile
224
+ });
225
+ if (result?.serviceRunner) {
226
+ activeApp = {
227
+ slavePaths: result.paths,
228
+ stop: async () => result.app.stop()
229
+ };
230
+ }
231
+ return;
232
+ }
233
+
211
234
  if (serviceRunner) {
212
235
  await registerServiceProcess();
213
236
  await runForeground();
@@ -0,0 +1,40 @@
1
+ {
2
+ "version": 1,
3
+ "repository": "https://github.com/clasen/Arisa.git",
4
+ "commit": "5959b7378c94b4d9c4864eba9e1ef9aa564f104a",
5
+ "tools": {
6
+ "master-slave": {
7
+ "files": {
8
+ "README.md": "39a546ca6046e622f92eff2d46f2c441beef43f150758799c68328bffd54efce",
9
+ "batch-runner.js": "be3939efe9c5800d3078c15a094f53db1094ef6e809d7826db7c47e3bec0c31b",
10
+ "chat-state-store.js": "50de39aa33432733bb688eb01968e314a32b169324a66d0f0089b2a19ed9c880",
11
+ "config.js": "ff1c1cb6701ae1da7a6eb1519ba96a416e48daa73c2155a6f93cf5d64f65cef8",
12
+ "index.js": "39dc9bf44f94864bc4fd09ce695cc2e084e59eea48d09764f0fb6f998faf9dd3",
13
+ "lib/bootstrap-url.js": "f05925cc0f0dff0882f94f9908c6f2c80aa8632cd51f63389381dcbb0f73afb3",
14
+ "lib/encrypted-frames.js": "47e1b11fafdbbcdfd86a0993a47a774e4f37d6386edeaf8ec1c8511b109728cf",
15
+ "lib/handshake-crypto.js": "9728b117aad7de53f5f0e255c0fd865ee1a19633368730a7634334d374094578",
16
+ "lib/ip-address.js": "3d5ab42d15fd47e79c5733114f905e995ef2d43226e29c434b250bf37878b0fa",
17
+ "lib/pairing-secret-store.js": "390f5d31d2a75707461147a1ec1b26de5f36da3ec4ec404b6f00a0f10c14065a",
18
+ "lib/profile-catalog.js": "d5cf43a564e45297d2c25642ba53a72a782ac2f3302f086658b3930236e6a154",
19
+ "lib/secure-store.js": "b8f365197f8e60d7f81fde6ed1cfc8851b603f6b0783e4cdc9036bb5c99726e1",
20
+ "master-domain.js": "10b7dfb43eb9939eb5baec54f742076cc73a2add6589a0ff57c51bc79c236faf",
21
+ "network-session.js": "db2d81a7ef47af29236d963b98e21414a11fef68e8b20895126eaad3f696ba3d",
22
+ "package.json": "8e9177238ba878dd4b51e32e0ed58a39c5789186c22b19ad7a43ab6caf9dc469",
23
+ "remote-runtime.js": "92c86adf575f2734c6f2f0d9004018f2019a8ac10288a1810425ce9fe0ffa129",
24
+ "slave-operations.js": "fec2e8addfff98f080284f24f722c1516124e5f6d12d8c03bb3c9cc0c475d1d6",
25
+ "state-store.js": "c56849a3f1b9c990128276e17963034a7836abacedc44f2e500d5732ebf9e451",
26
+ "test/batch-runner.test.js": "f14c6fddff5d6240008eef388df6a153e82490e534d06419d7bb267e4f4693a7",
27
+ "test/bootstrap-url.test.js": "62c112b1a38c59ea2c77f797e32873667b52db32386da661a6ae8410490126fe",
28
+ "test/encrypted-frames.test.js": "cfb3195d3a58df7dc570bc7840a21c1841a53e0abc8b92840e0c24ed3435bcc1",
29
+ "test/handshake-crypto.test.js": "f11d6e95658f2055d8256bd7dfb66b39bedf95bf62a3cec5525b109bb6f9bc95",
30
+ "test/master-domain.test.js": "7298766db3e6b186cca0109f352766e1bf417a8af12a58dd1c2b78fd1a61ff24",
31
+ "test/network-session.test.js": "2e459346cc9364be705bc5f54691245044588b2cd03ae77e2e870dee6dfbcca6",
32
+ "test/pairing-secret-store.test.js": "c51257365fd0b641c24768d710cdff2d1c5f8816bf90f66c2c01a226904cb43f",
33
+ "test/profile-catalog.test.js": "d8e297bee413d53ec13f2e1779c13f2e019cb8304976d567eab0e9f1ca36637c",
34
+ "test/remote-runtime.test.js": "a842c9934a3bf2e37c0a807ad2bc78f2f0d5adb38336c53369ddd92132bfb2b6",
35
+ "test/slave-operations.test.js": "a84526ad7554c7d03192a68655b4637bec18750559a5c3436f7471e912ca74cc",
36
+ "tool.manifest.json": "abf24c67b1d9ef0331ded59e04dcc092772419e74691c0d753ab51b40286d6f6"
37
+ }
38
+ }
39
+ }
40
+ }