arisa 4.3.4 → 5.0.2

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 (41) hide show
  1. package/AGENTS.md +18 -17
  2. package/README.md +30 -9
  3. package/package.json +6 -2
  4. package/pnpm-workspace.yaml +1 -0
  5. package/src/core/agent/agent-manager.js +288 -29
  6. package/src/core/agent/auth-flow.js +12 -8
  7. package/src/core/agent/model-selection.js +54 -14
  8. package/src/core/agent/model-speed.js +59 -0
  9. package/src/core/config/config-defaults.js +56 -4
  10. package/src/core/config/config-store.js +5 -1
  11. package/src/core/conversation/conversation-history-store.js +142 -0
  12. package/src/core/tasks/task-store.js +16 -0
  13. package/src/core/tools/daemon-health.js +11 -2
  14. package/src/core/tools/daemon-processes.js +92 -2
  15. package/src/core/tools/daemon-runtime.js +4 -2
  16. package/src/core/tools/ipc-client.js +15 -3
  17. package/src/core/tools/tool-registry.js +27 -0
  18. package/src/index.js +61 -6
  19. package/src/runtime/arisa-capabilities.js +45 -1
  20. package/src/runtime/bootstrap.js +3 -2
  21. package/src/runtime/create-app.js +47 -11
  22. package/src/runtime/doctor.js +307 -0
  23. package/src/runtime/log-viewer.js +165 -0
  24. package/src/runtime/paths.js +4 -1
  25. package/src/runtime/service-manager.js +106 -8
  26. package/src/runtime/tool-process-supervisor.js +107 -10
  27. package/src/transport/telegram/bot.js +533 -99
  28. package/src/transport/telegram/model-picker.js +28 -2
  29. package/test/agent-tool-policy.test.js +26 -1
  30. package/test/auth-flow.test.js +28 -2
  31. package/test/capabilities-security.test.js +37 -0
  32. package/test/context-and-task-bounds.test.js +279 -0
  33. package/test/daemon-runtime.test.js +130 -2
  34. package/test/dependency-warnings.test.js +17 -0
  35. package/test/doctor.test.js +90 -0
  36. package/test/log-viewer.test.js +90 -0
  37. package/test/model-selection.test.js +125 -2
  38. package/test/paths.test.js +8 -0
  39. package/test/pi-compaction.test.js +43 -0
  40. package/test/service-manager.test.js +234 -0
  41. package/test/task-store.test.js +31 -0
@@ -0,0 +1,59 @@
1
+ export const MODEL_SPEEDS = Object.freeze([1, 1.5]);
2
+
3
+ export function normalizeModelSpeed(speed) {
4
+ const value = Number(speed);
5
+ if (!MODEL_SPEEDS.includes(value)) {
6
+ throw new Error(`Invalid model speed: ${speed}`);
7
+ }
8
+ return value;
9
+ }
10
+
11
+ export function modelSupportsSpeed(model) {
12
+ return model?.provider === "openai-codex"
13
+ && model.api === "openai-codex-responses"
14
+ && typeof model.id === "string"
15
+ && (
16
+ model.id === "gpt-5.4"
17
+ || model.id === "gpt-5.5"
18
+ || model.id === "gpt-5.6"
19
+ || model.id.startsWith("gpt-5.6-")
20
+ );
21
+ }
22
+
23
+ export function clampModelSpeed(model, speed) {
24
+ const normalized = normalizeModelSpeed(speed);
25
+ return normalized === 1.5 && !modelSupportsSpeed(model) ? 1 : normalized;
26
+ }
27
+
28
+ export function speedToServiceTier(speed) {
29
+ return normalizeModelSpeed(speed) === 1.5 ? "priority" : "default";
30
+ }
31
+
32
+ export function createModelSpeedController(streamFn, initialSpeed) {
33
+ if (typeof streamFn !== "function") throw new Error("Pi stream function is unavailable");
34
+ let speed = normalizeModelSpeed(initialSpeed);
35
+ return {
36
+ get speed() {
37
+ return speed;
38
+ },
39
+ setSpeed(nextSpeed) {
40
+ speed = normalizeModelSpeed(nextSpeed);
41
+ },
42
+ streamFn(model, context, options) {
43
+ const serviceTier = speedToServiceTier(speed);
44
+ const onPayload = options?.onPayload;
45
+ return streamFn(model, context, {
46
+ ...options,
47
+ serviceTier,
48
+ async onPayload(payload, requestModel) {
49
+ const replacement = await onPayload?.(payload, requestModel);
50
+ const effectivePayload = replacement === undefined ? payload : replacement;
51
+ if (!effectivePayload || typeof effectivePayload !== "object" || Array.isArray(effectivePayload)) {
52
+ throw new Error("Pi provider payload is not an object");
53
+ }
54
+ return { ...effectivePayload, service_tier: serviceTier };
55
+ }
56
+ });
57
+ }
58
+ };
59
+ }
@@ -15,23 +15,75 @@ export const daemonConfigDefaults = Object.freeze({
15
15
  });
16
16
 
17
17
  export const telegramConfigDefaults = Object.freeze({
18
- modelPickerPageSize: 8
18
+ modelPickerPageSize: 8,
19
+ busyMessageMode: "steer"
20
+ });
21
+
22
+ export const doctorConfigDefaults = Object.freeze({
23
+ contextInspectionTimeoutMs: 5_000,
24
+ contextWarningPercent: 70,
25
+ contextCriticalPercent: 90,
26
+ contextInefficientMinTokens: 32_000,
27
+ contextToolResultWarningPercent: 60,
28
+ contextSingleMessageWarningPercent: 50
29
+ });
30
+
31
+ export const cliLogConfig = Object.freeze({
32
+ recentLines: 100,
33
+ followPollIntervalMs: 250
34
+ });
35
+
36
+ export const serviceConfigDefaults = Object.freeze({
37
+ shutdownTimeoutMs: 15_000,
38
+ shutdownPollIntervalMs: 100
19
39
  });
20
40
 
21
41
  export const piConfigDefaults = Object.freeze({
22
- thinkingLevel: "medium"
42
+ thinkingLevel: "medium",
43
+ speed: 1,
44
+ compaction: Object.freeze({
45
+ enabled: true,
46
+ reserveTokens: 16_384,
47
+ keepRecentTokens: 20_000
48
+ })
23
49
  });
24
50
 
51
+ function cloneChatModels(chatModels) {
52
+ if (!chatModels || typeof chatModels !== "object") return chatModels;
53
+ return Object.fromEntries(Object.entries(chatModels).map(([chatId, selection]) => [
54
+ chatId,
55
+ selection && typeof selection === "object" ? { ...selection } : selection
56
+ ]));
57
+ }
58
+
25
59
  export function applyConfigDefaults(config) {
60
+ const normalized = { ...config };
61
+ delete normalized.agent;
62
+ delete normalized.prime;
63
+ const configuredPi = normalized.pi || {};
64
+
26
65
  return {
27
- ...config,
66
+ ...normalized,
28
67
  telegram: {
29
68
  ...telegramConfigDefaults,
30
69
  ...(config.telegram || {})
31
70
  },
71
+ doctor: {
72
+ ...doctorConfigDefaults,
73
+ ...(config.doctor || {})
74
+ },
75
+ service: {
76
+ ...serviceConfigDefaults,
77
+ ...(config.service || {})
78
+ },
32
79
  pi: {
33
80
  ...piConfigDefaults,
34
- ...(config.pi || {})
81
+ ...configuredPi,
82
+ compaction: {
83
+ ...piConfigDefaults.compaction,
84
+ ...(configuredPi.compaction || {})
85
+ },
86
+ chatModels: cloneChatModels(configuredPi.chatModels)
35
87
  },
36
88
  daemons: {
37
89
  ...daemonConfigDefaults,
@@ -3,6 +3,10 @@ import path from "node:path";
3
3
  import { configFile } from "../../runtime/paths.js";
4
4
  import { applyConfigDefaults } from "./config-defaults.js";
5
5
 
6
+ export function prepareConfigForSave(config) {
7
+ return applyConfigDefaults(config);
8
+ }
9
+
6
10
  export async function loadConfig() {
7
11
  const raw = await readFile(configFile, "utf8");
8
12
  return applyConfigDefaults(JSON.parse(raw));
@@ -10,7 +14,7 @@ export async function loadConfig() {
10
14
 
11
15
  export async function saveConfig(config) {
12
16
  await mkdir(path.dirname(configFile), { recursive: true });
13
- await writeFile(configFile, `${JSON.stringify(applyConfigDefaults(config), null, 2)}\n`, "utf8");
17
+ await writeFile(configFile, `${JSON.stringify(prepareConfigForSave(config), null, 2)}\n`, "utf8");
14
18
  }
15
19
 
16
20
  export async function updateConfig(mutator) {
@@ -0,0 +1,142 @@
1
+ import crypto from "node:crypto";
2
+ import { mkdir, open, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { getChatConversationHistoryFile } from "../../runtime/paths.js";
5
+
6
+ const utf8Bom = "\uFEFF";
7
+
8
+ function normalizeText(value) {
9
+ return String(value || "").trim();
10
+ }
11
+
12
+ function parseHistory(contents) {
13
+ return String(contents || "")
14
+ .replace(/^\uFEFF/, "")
15
+ .split(/\r?\n/)
16
+ .filter(Boolean)
17
+ .map((line) => JSON.parse(line));
18
+ }
19
+
20
+ function serializeRecord(record) {
21
+ return `${JSON.stringify(record)}\n`;
22
+ }
23
+
24
+ export function formatPortableConversation(records) {
25
+ if (!records.length) return "";
26
+ const sections = [
27
+ "Portable Arisa conversation history.",
28
+ "This portable history belongs to the same Telegram chat and is independent of the active agent harness.",
29
+ "Use it as prior conversation context. Do not repeat it unless the user asks."
30
+ ];
31
+
32
+ for (const record of records) {
33
+ if (record.kind === "seed") {
34
+ sections.push(`Imported earlier conversation:\n${record.history}`);
35
+ continue;
36
+ }
37
+ const parts = [];
38
+ if (record.prompt) parts.push(`User or system request:\n${record.prompt}`);
39
+ if (record.response) parts.push(`Assistant response:\n${record.response}`);
40
+ if (parts.length) sections.push(parts.join("\n\n"));
41
+ }
42
+ return sections.join("\n\n---\n\n");
43
+ }
44
+
45
+ export class ConversationHistoryStore {
46
+ constructor({ historyFile = getChatConversationHistoryFile } = {}) {
47
+ this.locks = new Map();
48
+ this.historyFile = historyFile;
49
+ }
50
+
51
+ async withChatLock(chatId, work) {
52
+ const key = String(chatId);
53
+ const previous = this.locks.get(key) || Promise.resolve();
54
+ const current = previous.catch(() => {}).then(work);
55
+ this.locks.set(key, current);
56
+ try {
57
+ return await current;
58
+ } finally {
59
+ if (this.locks.get(key) === current) this.locks.delete(key);
60
+ }
61
+ }
62
+
63
+ async read(chatId) {
64
+ try {
65
+ return parseHistory(await readFile(this.historyFile(chatId), "utf8"));
66
+ } catch (error) {
67
+ if (error?.code === "ENOENT") return [];
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async hasEntries(chatId) {
73
+ return (await this.read(chatId)).length > 0;
74
+ }
75
+
76
+ async appendRecord(chatId, record) {
77
+ const file = this.historyFile(chatId);
78
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
79
+ const handle = await open(file, "a+", 0o600);
80
+ try {
81
+ const stats = await handle.stat();
82
+ if (stats.size === 0) await handle.write(utf8Bom);
83
+ await handle.write(serializeRecord(record));
84
+ } finally {
85
+ await handle.close();
86
+ }
87
+ }
88
+
89
+ async ensureSeed(chatId, { runtime, history }) {
90
+ const normalizedHistory = normalizeText(history);
91
+ if (!normalizedHistory) return false;
92
+ return this.withChatLock(chatId, async () => {
93
+ if ((await this.read(chatId)).length) return false;
94
+ await this.appendRecord(chatId, {
95
+ id: crypto.randomUUID(),
96
+ kind: "seed",
97
+ runtime,
98
+ history: normalizedHistory,
99
+ createdAt: new Date().toISOString()
100
+ });
101
+ return true;
102
+ });
103
+ }
104
+
105
+ async appendTurn(chatId, { runtime, prompt, response }) {
106
+ const normalizedPrompt = normalizeText(prompt);
107
+ const normalizedResponse = normalizeText(response);
108
+ if (!normalizedPrompt && !normalizedResponse) return null;
109
+ const record = {
110
+ id: crypto.randomUUID(),
111
+ kind: "turn",
112
+ runtime,
113
+ prompt: normalizedPrompt,
114
+ response: normalizedResponse,
115
+ createdAt: new Date().toISOString()
116
+ };
117
+ await this.withChatLock(chatId, () => this.appendRecord(chatId, record));
118
+ return record;
119
+ }
120
+
121
+ async reset(chatId, { runtime, history = "" } = {}) {
122
+ return this.withChatLock(chatId, async () => {
123
+ const file = this.historyFile(chatId);
124
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
125
+ const normalizedHistory = normalizeText(history);
126
+ const seed = normalizedHistory
127
+ ? serializeRecord({
128
+ id: crypto.randomUUID(),
129
+ kind: "seed",
130
+ runtime,
131
+ history: normalizedHistory,
132
+ createdAt: new Date().toISOString()
133
+ })
134
+ : "";
135
+ await writeFile(file, `${utf8Bom}${seed}`, { encoding: "utf8", mode: 0o600 });
136
+ });
137
+ }
138
+
139
+ async buildHandoff(chatId) {
140
+ return formatPortableConversation(await this.read(chatId));
141
+ }
142
+ }
@@ -99,6 +99,22 @@ export class TaskStore {
99
99
  return due;
100
100
  }
101
101
 
102
+ async recoverInterrupted() {
103
+ await this.reload();
104
+ const recovered = [];
105
+ const updatedAt = new Date().toISOString();
106
+
107
+ for (const task of this.tasks) {
108
+ if (task.status !== "running") continue;
109
+ task.status = "pending";
110
+ task.updatedAt = updatedAt;
111
+ recovered.push({ ...task });
112
+ }
113
+
114
+ if (recovered.length) await this.save();
115
+ return recovered;
116
+ }
117
+
102
118
  async complete(taskId) {
103
119
  await this.init();
104
120
  const task = this.tasks.find((item) => item.id === taskId);
@@ -17,7 +17,8 @@ function errorRecord(phase, error) {
17
17
  return {
18
18
  at: new Date().toISOString(),
19
19
  phase,
20
- message: error?.message || String(error)
20
+ message: error?.message || String(error),
21
+ ...(error?.code ? { code: error.code } : {})
21
22
  };
22
23
  }
23
24
 
@@ -25,6 +26,13 @@ function isTimeout(error) {
25
26
  return ["DAEMON_JOB_TIMEOUT", "DAEMON_OPERATION_TIMEOUT"].includes(error?.code);
26
27
  }
27
28
 
29
+ function recordedError(status, fallback) {
30
+ if (!status.lastError?.message) return fallback;
31
+ const error = new Error(status.lastError.message);
32
+ if (status.lastError.code) error.code = status.lastError.code;
33
+ return error;
34
+ }
35
+
28
36
  function healthDue(status, policy, now = Date.now()) {
29
37
  const heartbeatAt = new Date(status.heartbeatAt || 0).getTime();
30
38
  const healthAt = new Date(status.lastHealthCheckAt || 0).getTime();
@@ -156,6 +164,7 @@ export async function superviseDaemon(record, policy) {
156
164
  const alive = isProcessAlive(pid);
157
165
 
158
166
  if (!alive) {
167
+ if (status.state === "failed") return "failed";
159
168
  if (!record.autoStart && !status.restartRequested && ["stopped", "unhealthy", "failed"].includes(status.state)) {
160
169
  return status.state;
161
170
  }
@@ -165,7 +174,7 @@ export async function superviseDaemon(record, policy) {
165
174
  if (status.state === "restarting") {
166
175
  return restartIfDue(record, paths, status, policy);
167
176
  }
168
- return scheduleRestart(record, paths, status, policy, new Error("Daemon process exited"));
177
+ return scheduleRestart(record, paths, status, policy, recordedError(status, new Error("Daemon process exited")));
169
178
  }
170
179
 
171
180
  if (!healthDue(status, policy)) return "healthy";
@@ -97,6 +97,59 @@ export async function writeDaemonStatus(pathsOrIdentity, patch) {
97
97
  return next;
98
98
  }
99
99
 
100
+ function daemonDisposition({ state, alive, autoStart, restartRequested }) {
101
+ if (state === "failed") return "requires-attention";
102
+ if (state === "restarting" || restartRequested) return "automatic-retry";
103
+ if (["degraded", "unhealthy"].includes(state)) return autoStart ? "automatic-recovery" : "requires-attention";
104
+ if (state === "stopped") return autoStart ? "automatic-restart" : "leave-stopped";
105
+ if (state === "ready" && !alive) return autoStart ? "automatic-restart" : "start-on-demand";
106
+ if (state === "ready") return "available";
107
+ if (state === "starting") return "starting";
108
+ return autoStart ? "automatic-start" : "start-on-demand";
109
+ }
110
+
111
+ function redactDiagnosticText(value) {
112
+ if (!value) return null;
113
+ return String(value)
114
+ .replace(/\b(Bearer)\s+[^\s,;]+/gi, "$1 [redacted]")
115
+ .replace(/\b(token|secret|password|api[_-]?key)(\s*[=:]\s*)[^\s,;&]+/gi, "$1$2[redacted]")
116
+ .replace(/([?&](?:token|secret|password|api[_-]?key)=)[^\s&#]+/gi, "$1[redacted]")
117
+ .replace(/(https?:\/\/[^\s:/]+:)[^\s@/]+@/gi, "$1[redacted]@");
118
+ }
119
+
120
+ function diagnosticError(lastError) {
121
+ if (!lastError) return null;
122
+ return {
123
+ ...lastError,
124
+ message: redactDiagnosticText(lastError.message)
125
+ };
126
+ }
127
+
128
+ export async function readDaemonDiagnostic({ toolName, scope, autoStart = false }) {
129
+ const paths = daemonPaths({ toolName, scope });
130
+ const status = await readJson(paths.statusFile, {});
131
+ const { pid } = await readJson(paths.pidFile, {});
132
+ const state = status.state || "not-started";
133
+ const alive = isProcessAlive(pid);
134
+ const restartRequested = Boolean(status.restartRequested);
135
+ const hasActiveError = ["degraded", "unhealthy", "restarting", "failed"].includes(state);
136
+ return {
137
+ state,
138
+ alive,
139
+ pid: alive ? pid : null,
140
+ message: redactDiagnosticText(status.message),
141
+ lastError: hasActiveError ? diagnosticError(status.lastError) : null,
142
+ restart: {
143
+ attempts: Number(status.restartAttempts || 0),
144
+ requested: restartRequested,
145
+ nextAt: status.nextRestartAt || null
146
+ },
147
+ disposition: daemonDisposition({ state, alive, autoStart: Boolean(autoStart), restartRequested }),
148
+ updatedAt: status.updatedAt || null,
149
+ logFile: paths.logFile
150
+ };
151
+ }
152
+
100
153
  function registrationRecord({ paths, entryPath, autoStart, startupContext, current = {}, startedAt = null }) {
101
154
  return {
102
155
  toolName: paths.toolName,
@@ -168,6 +221,22 @@ async function acquireStartLock(paths, policy) {
168
221
  throw new Error(`Timed out acquiring daemon start lock for ${paths.toolName} (${paths.instanceId})`);
169
222
  }
170
223
 
224
+ export async function unregisterManagedDaemon(toolNameOrOptions, { scope } = {}) {
225
+ const paths = daemonPaths(toolNameOrOptions, scope);
226
+ const { pid } = await readJson(paths.pidFile, {});
227
+ if (isProcessAlive(pid)) {
228
+ throw new Error(`Refusing to unregister a live daemon: ${paths.toolName} (${paths.instanceId})`);
229
+ }
230
+ await Promise.all([
231
+ rm(paths.metaFile, { force: true }),
232
+ rm(paths.pidFile, { force: true }),
233
+ rm(paths.statusFile, { force: true }),
234
+ rm(paths.startLockFile, { force: true }),
235
+ rm(paths.commandsDir, { recursive: true, force: true })
236
+ ]);
237
+ return { toolName: paths.toolName, scope: paths.scope, instanceId: paths.instanceId };
238
+ }
239
+
171
240
  export async function stopManagedDaemon(toolNameOrOptions, {
172
241
  scope,
173
242
  signal = "SIGTERM",
@@ -285,9 +354,29 @@ export async function startManagedDaemon({
285
354
  } finally {
286
355
  closeSync(out);
287
356
  }
357
+ const pidRegistration = writeJson(paths.pidFile, { pid: child.pid, startedAt });
358
+ child.once("exit", async (exitCode, signal) => {
359
+ await pidRegistration.catch(() => {});
360
+ const currentPid = (await readJson(paths.pidFile, {})).pid;
361
+ const currentStatus = await readJson(paths.statusFile, {});
362
+ if (currentPid !== child.pid || ["stopped", "failed"].includes(currentStatus.state)) return;
363
+ const exitReason = signal ? `signal ${signal}` : `exit code ${exitCode}`;
364
+ await writeDaemonStatus(paths, {
365
+ state: "degraded",
366
+ pid: null,
367
+ heartbeatAt: null,
368
+ lastError: {
369
+ at: new Date().toISOString(),
370
+ phase: "process-exit",
371
+ message: `Daemon process exited with ${exitReason}`,
372
+ code: signal || `EXIT_${exitCode}`
373
+ },
374
+ message: `Daemon process exited with ${exitReason}`
375
+ }).catch(() => {});
376
+ });
288
377
  child.unref();
289
378
 
290
- await writeJson(paths.pidFile, { pid: child.pid, startedAt });
379
+ await pidRegistration;
291
380
  await writeDaemonStatus(paths, {
292
381
  state: "starting",
293
382
  pid: child.pid,
@@ -301,7 +390,8 @@ export async function startManagedDaemon({
301
390
  lastError: {
302
391
  at: new Date().toISOString(),
303
392
  phase: "start",
304
- message: error?.message || String(error)
393
+ message: error?.message || String(error),
394
+ ...(error?.code ? { code: error.code } : {})
305
395
  },
306
396
  message: error?.message || String(error)
307
397
  }).catch(() => {});
@@ -289,7 +289,8 @@ export function createDaemonRuntime({
289
289
  lastError: {
290
290
  at: new Date().toISOString(),
291
291
  phase: operation || "job",
292
- message: error?.message || String(error)
292
+ message: error?.message || String(error),
293
+ ...(error?.code ? { code: error.code } : {})
293
294
  },
294
295
  message: error?.message || String(error)
295
296
  });
@@ -315,7 +316,8 @@ export function createDaemonRuntime({
315
316
  lastError: {
316
317
  at: new Date().toISOString(),
317
318
  phase: "work-loop",
318
- message: error?.message || String(error)
319
+ message: error?.message || String(error),
320
+ ...(error?.code ? { code: error.code } : {})
319
321
  },
320
322
  message: error?.message || String(error)
321
323
  });
@@ -53,7 +53,12 @@ function requestIpc({ socketPath, request, timeoutMs = DEFAULT_TIMEOUT_MS }) {
53
53
  });
54
54
  }
55
55
 
56
- export function createArisaClient({ toolName, chatId = null, socketPath = process.env.ARISA_IPC_SOCKET || arisaIpcSocketFile } = {}) {
56
+ export function createArisaClient({
57
+ toolName,
58
+ chatId = null,
59
+ capabilityToken = process.env.ARISA_IPC_TOKEN || "",
60
+ socketPath = process.env.ARISA_IPC_SOCKET || arisaIpcSocketFile
61
+ } = {}) {
57
62
  if (typeof toolName !== "string" || !toolName.trim()) {
58
63
  throw new Error("toolName is required");
59
64
  }
@@ -66,6 +71,7 @@ export function createArisaClient({ toolName, chatId = null, socketPath = proces
66
71
  method,
67
72
  toolName,
68
73
  chatId,
74
+ capabilityToken,
69
75
  params
70
76
  }
71
77
  });
@@ -74,17 +80,23 @@ export function createArisaClient({ toolName, chatId = null, socketPath = proces
74
80
  artifacts: {
75
81
  createText: (params) => call("artifacts.createText", params),
76
82
  listRecent: (params) => call("artifacts.listRecent", params),
77
- get: (params) => call("artifacts.get", params)
83
+ get: (params) => call("artifacts.get", params),
84
+ deliver: (params) => call("artifacts.deliver", params, { timeoutMs: 120_000 })
78
85
  },
79
86
  tasks: {
80
87
  add: (params) => call("tasks.add", params),
81
88
  list: (params) => call("tasks.list", params),
82
- cancel: (params) => call("tasks.cancel", params)
89
+ cancel: (params) => call("tasks.cancel", params),
90
+ cancelAll: () => call("tasks.cancelAll")
83
91
  },
84
92
  agent: {
85
93
  enqueueEvent: (params) => call("agent.enqueueEvent", params)
86
94
  },
87
95
  tools: {
96
+ list: () => call("tools.list"),
97
+ help: (params) => call("tools.help", params),
98
+ skills: (params) => call("tools.skills", params),
99
+ setConfig: (params) => call("tools.setConfig", params),
88
100
  run: (params, options) => call("tools.run", params, options)
89
101
  },
90
102
  paths: {
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
5
5
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
6
6
  import { normalizeToolResult } from "./tool-result.js";
7
+ import { readDaemonDiagnostic } from "./daemon-processes.js";
7
8
  import { SkillRegistry } from "../skills/skill-registry.js";
8
9
 
9
10
  function toolEnv() {
@@ -105,6 +106,32 @@ export class ToolRegistry {
105
106
  }));
106
107
  }
107
108
 
109
+ async listWithRuntime(chatId = null) {
110
+ return Promise.all(this.list().map(async (listedTool) => {
111
+ const daemon = this.get(listedTool.name)?.daemon;
112
+ if (!daemon) return listedTool;
113
+ if (daemon.scope === "chat" && (chatId == null || chatId === "")) {
114
+ throw new Error(`Daemon status for ${listedTool.name} requires chatId`);
115
+ }
116
+ const scope = daemon.scope === "chat"
117
+ ? { type: "chat", chatId }
118
+ : { type: "global" };
119
+ return {
120
+ ...listedTool,
121
+ daemon: {
122
+ scope: daemon.scope,
123
+ autoStart: Boolean(daemon.autoStart),
124
+ health: daemon.health || null,
125
+ runtime: await readDaemonDiagnostic({
126
+ toolName: listedTool.name,
127
+ scope,
128
+ autoStart: daemon.autoStart
129
+ })
130
+ }
131
+ };
132
+ }));
133
+ }
134
+
108
135
  get(name) {
109
136
  return this.tools.get(name) || null;
110
137
  }