arisa 5.1.13 → 5.1.49

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 (49) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +2 -0
  3. package/package.json +8 -10
  4. package/src/core/agent/agent-manager.js +132 -70
  5. package/src/core/agent/pi-runtime.js +0 -8
  6. package/src/core/agent/system-shell-tool.js +13 -2
  7. package/src/core/artifacts/artifact-store.js +17 -18
  8. package/src/core/config/config-defaults.js +2 -2
  9. package/src/core/conversation/session-seed-store.js +85 -0
  10. package/src/core/tools/ipc-client.js +0 -2
  11. package/src/core/tools/official-tool-installer.js +78 -6
  12. package/src/core/tools/tool-dependencies.js +99 -0
  13. package/src/core/tools/tool-output-materializer.js +41 -0
  14. package/src/core/tools/tool-registry.js +145 -28
  15. package/src/official-tools.lock.json +209 -5
  16. package/src/runtime/arisa-capabilities.js +12 -1
  17. package/src/runtime/create-app.js +1 -0
  18. package/src/runtime/doctor.js +72 -23
  19. package/src/runtime/headless-tool-executor.js +2 -32
  20. package/src/runtime/paths.js +7 -1
  21. package/src/runtime/restart-receipt.js +90 -0
  22. package/src/runtime/tool-usage-report.js +25 -10
  23. package/src/transport/telegram/bot.js +403 -1015
  24. package/src/transport/telegram/chat-queue.js +132 -0
  25. package/src/transport/telegram/media.js +2 -2
  26. package/src/transport/telegram/model-callback.js +211 -0
  27. package/src/transport/telegram/model-controls.js +164 -0
  28. package/src/transport/telegram/prompt-builders.js +372 -0
  29. package/src/transport/telegram/task-dispatcher.js +94 -0
  30. package/src/transport/telegram/update-command.js +1 -1
  31. package/src/transport/telegram/workspace-group.js +83 -0
  32. package/test/agent-tool-policy.test.js +7 -1
  33. package/test/capabilities-security.test.js +21 -0
  34. package/test/context-and-task-bounds.test.js +33 -5
  35. package/test/doctor.test.js +57 -4
  36. package/test/model-selection.test.js +47 -1
  37. package/test/official-tool-dependencies.test.js +25 -0
  38. package/test/official-tool-installer.test.js +37 -9
  39. package/test/paths.test.js +4 -4
  40. package/test/restart-receipt.test.js +39 -0
  41. package/test/session-start-operational-notes.test.js +47 -0
  42. package/test/telegram-prompt-builders.test.js +33 -0
  43. package/test/telegram-task-dispatcher.test.js +102 -0
  44. package/test/telegram-workspace-group.test.js +76 -0
  45. package/test/tool-dependencies.test.js +53 -0
  46. package/test/tool-registry-run.test.js +81 -1
  47. package/test/tool-usage.test.js +26 -4
  48. package/test/topic-initialization.test.js +66 -0
  49. package/src/core/conversation/conversation-history-store.js +0 -142
@@ -14,6 +14,7 @@ import {
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
16
  import { getToolDir } from "../../runtime/paths.js";
17
+ import { normalizeToolDependencies, resolveToolDependencyPlan, satisfiesToolVersion } from "./tool-dependencies.js";
17
18
 
18
19
  const bundledLockFile = new URL("../../official-tools.lock.json", import.meta.url);
19
20
 
@@ -46,7 +47,8 @@ export function validateOfficialToolLock(lock, toolName) {
46
47
  if (!COMMIT_PATTERN.test(String(lock.commit || ""))) {
47
48
  throw new Error("Official tool lock requires an immutable 40-character commit");
48
49
  }
49
- const files = lock.tools?.[toolName]?.files;
50
+ const entry = lock.tools?.[toolName];
51
+ const files = entry?.files;
50
52
  if (!files || typeof files !== "object" || Array.isArray(files) || !Object.keys(files).length) {
51
53
  throw new Error(`Official tool lock has no files for ${toolName}`);
52
54
  }
@@ -56,7 +58,12 @@ export function validateOfficialToolLock(lock, toolName) {
56
58
  throw new Error(`Invalid SHA-256 digest for ${toolName}/${file}`);
57
59
  }
58
60
  }
59
- return { repository: lock.repository, commit: lock.commit, files };
61
+ const toolDependencies = normalizeToolDependencies(entry.toolDependencies);
62
+ const toolVersion = entry.version == null ? null : String(entry.version);
63
+ if (toolVersion && !satisfiesToolVersion(toolVersion, toolVersion)) {
64
+ throw new Error(`Invalid locked tool version for ${toolName}: ${toolVersion}`);
65
+ }
66
+ return { repository: lock.repository, commit: lock.commit, files, toolVersion, toolDependencies };
60
67
  }
61
68
 
62
69
  async function walkFiles(root, relative = "") {
@@ -133,11 +140,36 @@ async function checkoutRepository({ repository, commit, checkoutDir }) {
133
140
  if (resolved !== commit) throw new Error(`Official tool checkout resolved ${resolved}, expected ${commit}`);
134
141
  }
135
142
 
136
- async function validateEntrypoint(toolDir, toolName) {
143
+ async function installPackageDependencies(toolDir) {
144
+ let packageJson;
145
+ try {
146
+ packageJson = JSON.parse(await readFile(path.join(toolDir, "package.json"), "utf8"));
147
+ } catch (error) {
148
+ if (error?.code === "ENOENT") return { installed: false };
149
+ throw error;
150
+ }
151
+ const dependencyCount = Object.keys(packageJson.dependencies || {}).length + Object.keys(packageJson.optionalDependencies || {}).length;
152
+ if (!dependencyCount) return { installed: false };
153
+ if (await exists(path.join(toolDir, "package-lock.json"))) {
154
+ await runCommand("npm", ["ci", "--omit=dev"], { cwd: toolDir });
155
+ } else {
156
+ await runCommand("npm", ["install", "--omit=dev"], { cwd: toolDir });
157
+ }
158
+ return { installed: true, dependencyCount };
159
+ }
160
+
161
+ async function validateEntrypoint(toolDir, toolName, locked) {
137
162
  const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
138
163
  if (manifest.name !== toolName) {
139
164
  throw new Error(`Official tool manifest mismatch: expected ${toolName}, got ${manifest.name || "missing"}`);
140
165
  }
166
+ if (locked.toolVersion && manifest.version !== locked.toolVersion) {
167
+ throw new Error(`Official tool version mismatch: expected ${locked.toolVersion}, got ${manifest.version || "missing"}`);
168
+ }
169
+ const manifestDependencies = normalizeToolDependencies(manifest.toolDependencies);
170
+ if (JSON.stringify(manifestDependencies) !== JSON.stringify(locked.toolDependencies)) {
171
+ throw new Error(`Official tool dependency metadata mismatch: ${toolName}`);
172
+ }
141
173
  const entry = manifest.entry || "index.js";
142
174
  const entryPath = path.join(toolDir, entry);
143
175
  if (!(await exists(entryPath))) throw new Error(`Official tool entry does not exist: ${entry}`);
@@ -151,6 +183,7 @@ export async function installLockedOfficialTool({
151
183
  destination,
152
184
  scratchRoot = os.tmpdir(),
153
185
  checkout = checkoutRepository,
186
+ installDependencies = installPackageDependencies,
154
187
  validate = validateEntrypoint
155
188
  }) {
156
189
  const locked = validateOfficialToolLock(lock, toolName);
@@ -165,7 +198,8 @@ export async function installLockedOfficialTool({
165
198
  const sourceDir = path.join(checkoutDir, "tools", toolName);
166
199
  await verifyOfficialToolTree(sourceDir, locked.files);
167
200
  await cp(sourceDir, stageDir, { recursive: true, errorOnExist: true, force: false });
168
- await validate(stageDir, toolName);
201
+ await installDependencies(stageDir, toolName);
202
+ await validate(stageDir, toolName, locked);
169
203
  await mkdir(path.dirname(destination), { recursive: true });
170
204
  await rename(stageDir, destination);
171
205
  return { toolName, destination, commit: locked.commit, files: Object.keys(locked.files).length };
@@ -174,10 +208,48 @@ export async function installLockedOfficialTool({
174
208
  }
175
209
  }
176
210
 
211
+ async function installedToolVersion(toolName) {
212
+ try {
213
+ const manifest = JSON.parse(await readFile(path.join(getToolDir(toolName), "tool.manifest.json"), "utf8"));
214
+ return typeof manifest.version === "string" ? manifest.version : null;
215
+ } catch (error) {
216
+ if (error?.code === "ENOENT") return undefined;
217
+ throw error;
218
+ }
219
+ }
220
+
177
221
  export async function installBundledOfficialTool(toolName, {
178
222
  lockFile = bundledLockFile,
179
- install = installLockedOfficialTool
223
+ install = installLockedOfficialTool,
224
+ resolveInstalledVersion = installedToolVersion
180
225
  } = {}) {
181
226
  const lock = JSON.parse(await readFile(lockFile, "utf8"));
182
- return install({ toolName, lock, destination: getToolDir(toolName) });
227
+ const entries = new Map(Object.entries(lock.tools || {}).map(([name, entry]) => [name, {
228
+ version: entry.version || null,
229
+ toolDependencies: normalizeToolDependencies(entry.toolDependencies)
230
+ }]));
231
+ const plan = resolveToolDependencyPlan(entries, toolName);
232
+ const dependencies = [];
233
+ let result = null;
234
+ for (const name of plan) {
235
+ const locked = validateOfficialToolLock(lock, name);
236
+ if (name !== toolName) {
237
+ const installedVersion = await resolveInstalledVersion(name);
238
+ if (installedVersion !== undefined) {
239
+ const requiredRanges = plan
240
+ .map((dependentName) => entries.get(dependentName)?.toolDependencies?.[name])
241
+ .filter(Boolean);
242
+ const incompatibleRange = requiredRanges.find((range) => !satisfiesToolVersion(installedVersion, range));
243
+ if (incompatibleRange || (!requiredRanges.length && locked.toolVersion !== installedVersion)) {
244
+ throw new Error(`Installed tool dependency is incompatible: ${name}@${installedVersion} does not satisfy ${incompatibleRange || locked.toolVersion || "locked version"}`);
245
+ }
246
+ dependencies.push({ name, version: installedVersion, status: "already-installed" });
247
+ continue;
248
+ }
249
+ }
250
+ const installed = await install({ toolName: name, lock, destination: getToolDir(name) });
251
+ if (name === toolName) result = installed;
252
+ else dependencies.push({ name, version: locked.toolVersion, status: "installed" });
253
+ }
254
+ return { ...result, dependencies };
183
255
  }
@@ -0,0 +1,99 @@
1
+ const TOOL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
2
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
3
+ const RANGE_PATTERN = /^(\^)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
4
+
5
+ function versionParts(version) {
6
+ const match = VERSION_PATTERN.exec(String(version || ""));
7
+ return match ? match.slice(1).map(Number) : null;
8
+ }
9
+
10
+ function compareVersions(left, right) {
11
+ for (let index = 0; index < 3; index += 1) {
12
+ if (left[index] !== right[index]) return left[index] - right[index];
13
+ }
14
+ return 0;
15
+ }
16
+
17
+ export function normalizeToolDependencies(value) {
18
+ if (value == null) return {};
19
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("toolDependencies must be an object");
20
+ const normalized = {};
21
+ for (const [name, range] of Object.entries(value)) {
22
+ if (!TOOL_NAME_PATTERN.test(name)) throw new Error(`Invalid tool dependency name: ${name}`);
23
+ if (!RANGE_PATTERN.test(String(range || ""))) throw new Error(`Unsupported tool dependency range for ${name}: ${range || "empty"}`);
24
+ normalized[name] = String(range);
25
+ }
26
+ return normalized;
27
+ }
28
+
29
+ export function satisfiesToolVersion(version, range) {
30
+ const actual = versionParts(version);
31
+ const match = RANGE_PATTERN.exec(String(range || ""));
32
+ if (!actual || !match) return false;
33
+ const expected = match.slice(2).map(Number);
34
+ if (!match[1]) return compareVersions(actual, expected) === 0;
35
+ if (compareVersions(actual, expected) < 0) return false;
36
+ if (expected[0] > 0) return actual[0] === expected[0];
37
+ if (expected[1] > 0) return actual[0] === 0 && actual[1] === expected[1];
38
+ return actual[0] === 0 && actual[1] === 0 && actual[2] === expected[2];
39
+ }
40
+
41
+ export function inspectToolDependencies(tools, rootName = null) {
42
+ const issues = [];
43
+ const visiting = new Set();
44
+ const visited = new Set();
45
+ const visit = (name, chain = []) => {
46
+ if (visiting.has(name)) {
47
+ issues.push({ tool: name, type: "cycle", dependency: name, chain: [...chain, name] });
48
+ return;
49
+ }
50
+ if (visited.has(name)) return;
51
+ const tool = tools.get(name);
52
+ if (!tool) return;
53
+ visiting.add(name);
54
+ for (const [dependency, range] of Object.entries(normalizeToolDependencies(tool.toolDependencies))) {
55
+ const installed = tools.get(dependency);
56
+ if (!installed) {
57
+ issues.push({ tool: name, type: "missing", dependency, range });
58
+ continue;
59
+ }
60
+ if (!satisfiesToolVersion(installed.version, range)) {
61
+ issues.push({ tool: name, type: "incompatible", dependency, range, installedVersion: installed.version || null });
62
+ continue;
63
+ }
64
+ visit(dependency, [...chain, name]);
65
+ }
66
+ visiting.delete(name);
67
+ visited.add(name);
68
+ };
69
+ if (rootName) visit(rootName);
70
+ else for (const name of tools.keys()) visit(name);
71
+ return issues;
72
+ }
73
+
74
+ export function resolveToolDependencyPlan(entries, rootName) {
75
+ const tools = entries instanceof Map ? entries : new Map(Object.entries(entries || {}));
76
+ const order = [];
77
+ const visiting = new Set();
78
+ const visited = new Set();
79
+ const visit = (name, chain = []) => {
80
+ const tool = tools.get(name);
81
+ if (!tool) throw new Error(`Official tool dependency is not locked: ${name}`);
82
+ if (visiting.has(name)) throw new Error(`Circular official tool dependency: ${[...chain, name].join(" -> ")}`);
83
+ if (visited.has(name)) return;
84
+ visiting.add(name);
85
+ for (const [dependency, range] of Object.entries(normalizeToolDependencies(tool.toolDependencies))) {
86
+ const lockedDependency = tools.get(dependency);
87
+ if (!lockedDependency) throw new Error(`Official tool dependency is not locked: ${name} -> ${dependency}`);
88
+ if (!satisfiesToolVersion(lockedDependency.version, range)) {
89
+ throw new Error(`Locked tool dependency is incompatible: ${name} requires ${dependency}@${range}, lock has ${lockedDependency.version || "no version"}`);
90
+ }
91
+ visit(dependency, [...chain, name]);
92
+ }
93
+ visiting.delete(name);
94
+ visited.add(name);
95
+ order.push(name);
96
+ };
97
+ visit(rootName);
98
+ return order;
99
+ }
@@ -0,0 +1,41 @@
1
+ import path from "node:path";
2
+ import { unlink } from "node:fs/promises";
3
+
4
+ export async function materializeToolOutput({ result, name, chatId, artifactStore, taskStore, taskContext = null }) {
5
+ const chatArtifactStore = artifactStore.forChat(chatId);
6
+
7
+ if (result.output?.text) {
8
+ const artifact = await chatArtifactStore.createText({
9
+ text: result.output.text,
10
+ source: { type: "tool", toolName: name },
11
+ metadata: { tool: name }
12
+ });
13
+ result.output.artifactId = artifact.id;
14
+ }
15
+
16
+ if (result.output?.filePath) {
17
+ const generated = await chatArtifactStore.createFromFile({
18
+ originalPath: result.output.filePath,
19
+ fileName: result.output.fileName || path.basename(result.output.filePath),
20
+ kind: result.output.kind || "file",
21
+ mimeType: result.output.mimeType || "application/octet-stream",
22
+ source: { type: "tool", toolName: name },
23
+ metadata: { tool: name, delivery: result.output.delivery }
24
+ });
25
+ result.output.artifactId = generated.id;
26
+ await unlink(result.output.filePath).catch(() => {});
27
+ }
28
+
29
+ if (result.asyncTask || result.asyncTasks?.length) {
30
+ result.asyncTasks = await taskStore.addMany(result.asyncTasks || [result.asyncTask], {
31
+ payload: {
32
+ chatId,
33
+ ...(taskContext ? { telegramContext: taskContext } : {})
34
+ },
35
+ source: { type: "tool", toolName: name, chatId }
36
+ });
37
+ delete result.asyncTask;
38
+ }
39
+
40
+ return result;
41
+ }
@@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from "node:fs/prom
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
- import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
5
+ import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolStateDir, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
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";
@@ -10,22 +10,59 @@ import { createDaemonRuntime, DAEMON_EVENT_TYPES, DAEMON_PROTOCOL_VERSION } from
10
10
  import { daemonConfigDefaults } from "../config/config-defaults.js";
11
11
  import { SkillRegistry } from "../skills/skill-registry.js";
12
12
  import { ToolUsageStore } from "./tool-usage-store.js";
13
+ import { inspectToolDependencies, normalizeToolDependencies } from "./tool-dependencies.js";
13
14
 
14
15
  function toolEnv() {
15
16
  return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir, ARISA_IPC_SOCKET: arisaIpcSocketFile };
16
17
  }
17
18
 
18
- function runProcess(command, args, options = {}) {
19
- return new Promise((resolve) => {
20
- const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
21
- let stdout = "";
22
- let stderr = "";
23
- child.stdout.on("data", (d) => { stdout += d.toString(); });
24
- child.stderr.on("data", (d) => { stderr += d.toString(); });
25
- child.on("close", (code) => resolve({ code, stdout, stderr }));
19
+ const defaultToolHelpTimeoutMs = 10_000;
20
+ const defaultToolRunTimeoutMs = 30 * 60_000;
21
+ const defaultToolKillGraceMs = 2_000;
22
+
23
+ function positiveDuration(value, fallback) {
24
+ return Number.isFinite(value) && value > 0 ? value : fallback;
25
+ }
26
+
27
+ function waitForToolProcess(child, { timeoutMs, killGraceMs, label }) {
28
+ return new Promise((resolve, reject) => {
29
+ let timedOut = false;
30
+ let forceTimer = null;
31
+ const timeout = setTimeout(() => {
32
+ timedOut = true;
33
+ child.kill("SIGTERM");
34
+ forceTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
35
+ }, timeoutMs);
36
+
37
+ const finish = (callback, value) => {
38
+ clearTimeout(timeout);
39
+ clearTimeout(forceTimer);
40
+ callback(value);
41
+ };
42
+
43
+ child.once("error", (error) => finish(reject, error));
44
+ child.once("close", (code) => {
45
+ if (!timedOut) {
46
+ finish(resolve, code);
47
+ return;
48
+ }
49
+ const error = new Error(`${label} timed out after ${timeoutMs}ms`);
50
+ error.code = "TOOL_PROCESS_TIMEOUT";
51
+ finish(reject, error);
52
+ });
26
53
  });
27
54
  }
28
55
 
56
+ async function runProcess(command, args, { timeoutMs, killGraceMs, label, ...options } = {}) {
57
+ const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
58
+ let stdout = "";
59
+ let stderr = "";
60
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
61
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
62
+ const code = await waitForToolProcess(child, { timeoutMs, killGraceMs, label });
63
+ return { code, stdout, stderr };
64
+ }
65
+
29
66
  function requirementNames(requirements) {
30
67
  if (Array.isArray(requirements)) {
31
68
  return requirements.map((item) => typeof item === "string" ? item : item?.name).filter(Boolean);
@@ -120,7 +157,7 @@ export function createToolOutputParser(name, { onEvent, maxFrameBytes = 1_048_57
120
157
  };
121
158
  }
122
159
 
123
- async function runToolProcess(command, args, { onEvent, maxFrameBytes, ...options } = {}) {
160
+ async function runToolProcess(command, args, { onEvent, maxFrameBytes, timeoutMs, killGraceMs, label, ...options } = {}) {
124
161
  const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
125
162
  const parser = createToolOutputParser(path.basename(args[0] || command), { onEvent, maxFrameBytes });
126
163
  const stderrChunks = [];
@@ -138,13 +175,15 @@ async function runToolProcess(command, args, { onEvent, maxFrameBytes, ...option
138
175
  }
139
176
  return Buffer.concat(stderrChunks).toString("utf8");
140
177
  })();
141
- const exitPromise = new Promise((resolve, reject) => {
142
- child.once("error", reject);
143
- child.once("close", resolve);
144
- });
145
178
  child.stdout.resume();
146
179
  child.stderr.resume();
147
- const code = await exitPromise;
180
+ let code;
181
+ try {
182
+ code = await waitForToolProcess(child, { timeoutMs, killGraceMs, label });
183
+ } catch (error) {
184
+ await Promise.allSettled([stdoutTask, stderrTask]);
185
+ throw error;
186
+ }
148
187
  const [parsed, stderr] = await Promise.all([stdoutTask, stderrTask]);
149
188
  return { code, parsed, stderr };
150
189
  }
@@ -203,17 +242,50 @@ function formatSemanticMetadata(tool) {
203
242
  ].join("\n");
204
243
  }
205
244
 
245
+ function formatToolDependencies(tool, tools) {
246
+ const dependencies = Object.entries(tool.toolDependencies || {});
247
+ if (!dependencies.length) return null;
248
+ const lines = dependencies.map(([name, range]) => {
249
+ const issue = inspectToolDependencies(tools, tool.name).find((item) => item.dependency === name);
250
+ return `- ${name}@${range}: ${issue ? issue.type : "ready"}`;
251
+ });
252
+ return `Tool dependencies:\n${lines.join("\n")}`;
253
+ }
254
+
255
+ async function readOfficialToolNames() {
256
+ const baselinesDir = path.join(getToolStateDir("official-tool-sync"), "baselines");
257
+ try {
258
+ const entries = await readdir(baselinesDir, { withFileTypes: true });
259
+ return new Set(entries
260
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
261
+ .map((entry) => entry.name.slice(0, -5)));
262
+ } catch (error) {
263
+ if (error?.code === "ENOENT") return new Set();
264
+ throw error;
265
+ }
266
+ }
267
+
206
268
  export class ToolRegistry {
207
- constructor({ logger, usageStore = new ToolUsageStore() } = {}) {
269
+ constructor({
270
+ logger,
271
+ usageStore = new ToolUsageStore(),
272
+ resolveOfficialToolNames = readOfficialToolNames,
273
+ helpTimeoutMs = defaultToolHelpTimeoutMs,
274
+ runTimeoutMs = defaultToolRunTimeoutMs,
275
+ killGraceMs = defaultToolKillGraceMs
276
+ } = {}) {
208
277
  this.logger = logger;
278
+ this.helpTimeoutMs = positiveDuration(helpTimeoutMs, defaultToolHelpTimeoutMs);
279
+ this.runTimeoutMs = positiveDuration(runTimeoutMs, defaultToolRunTimeoutMs);
280
+ this.killGraceMs = positiveDuration(killGraceMs, defaultToolKillGraceMs);
209
281
  this.tools = new Map();
210
282
  this.skillRegistry = new SkillRegistry();
211
283
  this.usageStore = usageStore;
284
+ this.resolveOfficialToolNames = resolveOfficialToolNames;
212
285
  }
213
286
 
214
- async load() {
215
- this.tools.clear();
216
-
287
+ async buildSnapshot() {
288
+ const snapshot = new Map();
217
289
  let entries = [];
218
290
  try {
219
291
  entries = await readdir(userToolsRoot, { withFileTypes: true });
@@ -228,13 +300,14 @@ export class ToolRegistry {
228
300
  const configPath = path.join(toolDir, "config.js");
229
301
  try {
230
302
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
231
- if (this.tools.has(manifest.name)) continue;
303
+ if (snapshot.has(manifest.name)) continue;
232
304
  const configSource = await readFile(configPath, "utf8");
233
305
  const defaults = parseConfigModule(configSource);
234
306
  const config = await loadToolConfig(manifest.name, defaults);
235
307
  const skillHints = this.skillRegistry.normalizeHints(manifest);
236
- this.tools.set(manifest.name, {
308
+ snapshot.set(manifest.name, {
237
309
  ...manifest,
310
+ toolDependencies: normalizeToolDependencies(manifest.toolDependencies),
238
311
  category: normalizeCategory(manifest.category),
239
312
  keywords: normalizeKeywords(manifest.keywords),
240
313
  skillHints,
@@ -249,8 +322,13 @@ export class ToolRegistry {
249
322
  // ignore invalid tool dirs in v1
250
323
  }
251
324
  }
325
+ return snapshot;
326
+ }
252
327
 
253
- this.logger?.log("tools", `loaded ${this.tools.size} tool(s)`);
328
+ async load() {
329
+ const snapshot = await this.buildSnapshot();
330
+ this.tools = snapshot;
331
+ this.logger?.log("tools", `loaded ${snapshot.size} tool(s)`);
254
332
  }
255
333
 
256
334
  list() {
@@ -259,6 +337,7 @@ export class ToolRegistry {
259
337
  version: typeof tool.version === "string" ? tool.version : null,
260
338
  packageDigest: typeof tool.packageDigest === "string" ? tool.packageDigest : null,
261
339
  requirements: requirementNames(tool.requirements),
340
+ toolDependencies: tool.toolDependencies || {},
262
341
  description: tool.description,
263
342
  input: tool.input,
264
343
  output: tool.output,
@@ -306,12 +385,19 @@ export class ToolRegistry {
306
385
  async help(name) {
307
386
  const tool = this.get(name);
308
387
  if (!tool) throw new Error(`Tool not found: ${name}`);
309
- const result = await runProcess("node", [tool.entry, "--help"], { cwd: tool.dir, env: toolEnv() });
388
+ const result = await runProcess("node", [tool.entry, "--help"], {
389
+ cwd: tool.dir,
390
+ env: toolEnv(),
391
+ timeoutMs: this.helpTimeoutMs,
392
+ killGraceMs: this.killGraceMs,
393
+ label: `Tool help for ${name}`
394
+ });
310
395
  const help = result.stdout || result.stderr;
311
396
  const skills = await this.resolveSkills(name);
312
397
  const sections = [
313
398
  help.trimEnd(),
314
- formatSemanticMetadata(tool)
399
+ formatSemanticMetadata(tool),
400
+ formatToolDependencies(tool, this.tools)
315
401
  ];
316
402
  if (skills.length) {
317
403
  const skillHelp = skills.map((item) => [
@@ -360,16 +446,32 @@ export class ToolRegistry {
360
446
  return { ok: true, tool: name, field, configPath };
361
447
  }
362
448
 
449
+ dependencyIssues(name = null) {
450
+ return inspectToolDependencies(this.tools, name);
451
+ }
452
+
363
453
  async usage(chatId) {
364
- const counts = await this.usageStore.counts(chatId);
365
- return this.list()
366
- .map((tool) => ({ name: tool.name, count: counts[tool.name] || 0 }))
454
+ const [counts, officialNames] = await Promise.all([
455
+ this.usageStore.counts(chatId),
456
+ this.resolveOfficialToolNames()
457
+ ]);
458
+ const names = new Set([
459
+ ...this.list().map((tool) => tool.name),
460
+ ...Object.keys(counts)
461
+ ]);
462
+ return [...names]
463
+ .map((name) => ({ name, count: counts[name] || 0, official: officialNames.has(name) }))
367
464
  .sort((left, right) => left.name.localeCompare(right.name));
368
465
  }
369
466
 
370
467
  async run({ name, request, chatId = null, onEvent = null }) {
371
468
  const tool = this.get(name);
372
469
  if (!tool) throw new Error(`Tool not found: ${name}`);
470
+ const dependencyIssue = this.dependencyIssues(name)[0];
471
+ if (dependencyIssue) {
472
+ const version = dependencyIssue.installedVersion ? `; installed ${dependencyIssue.installedVersion}` : "";
473
+ throw new Error(`Tool dependency ${dependencyIssue.type}: ${dependencyIssue.tool} requires ${dependencyIssue.dependency}@${dependencyIssue.range || "valid"}${version}`);
474
+ }
373
475
  await this.usageStore.record(chatId, name).catch((error) => {
374
476
  this.logger?.error("tools", `could not record ${name} usage: ${error?.message || String(error)}`);
375
477
  });
@@ -399,7 +501,10 @@ export class ToolRegistry {
399
501
  cwd: tool.dir,
400
502
  env: toolEnv(),
401
503
  onEvent,
402
- maxFrameBytes: daemonConfigDefaults.ipcFrameBytes
504
+ maxFrameBytes: daemonConfigDefaults.ipcFrameBytes,
505
+ timeoutMs: this.runTimeoutMs,
506
+ killGraceMs: this.killGraceMs,
507
+ label: `Tool run for ${name}`
403
508
  });
404
509
  if (processResult.stderr.trim()) {
405
510
  this.logger?.log("tools", `${name} stderr: ${processResult.stderr.trim()}`);
@@ -416,6 +521,18 @@ export class ToolRegistry {
416
521
  }
417
522
  return normalized;
418
523
  } catch (error) {
524
+ if (error?.code === "TOOL_PROCESS_TIMEOUT") {
525
+ return normalizeToolResult(name, {
526
+ ok: false,
527
+ status: "outcome_uncertain",
528
+ error: error.message,
529
+ resolution: {
530
+ type: "status_check_required",
531
+ retry: false,
532
+ message: "The tool process was terminated after timing out. Check external state before retrying."
533
+ }
534
+ });
535
+ }
419
536
  return normalizeToolResult(name, {
420
537
  ok: false,
421
538
  error: error?.message || `Invalid tool response for ${name}`