arisa 3.1.4 → 3.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "3.1.4",
3
+ "version": "3.1.6",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3,10 +3,12 @@ import { unlink } from "node:fs/promises";
3
3
  import { createAgentSession, SessionManager, defineTool } from "@mariozechner/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
- import { loadProjectInstructions } from "./project-instructions.js";
7
6
  import { arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
7
+ import { withTimeout } from "./prompt-timeout.js";
8
8
  import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
9
9
 
10
+ const piValidationTimeoutMs = 60_000;
11
+
10
12
  function isLocalBaseUrl(value) {
11
13
  if (typeof value !== "string" || !value.trim()) return false;
12
14
  try {
@@ -21,6 +23,32 @@ function requiresProviderAuth(model) {
21
23
  return !isLocalBaseUrl(model?.baseUrl);
22
24
  }
23
25
 
26
+ function mimeMatches(pattern, mimeType = "") {
27
+ if (!pattern || !mimeType) return false;
28
+ if (pattern === mimeType) return true;
29
+ if (pattern.endsWith("/*")) return mimeType.startsWith(`${pattern.slice(0, -2)}/`);
30
+ return false;
31
+ }
32
+
33
+ function toolSupportsTextInput(tool) {
34
+ return (tool.input || []).some((input) => mimeMatches(input, "text/plain"));
35
+ }
36
+
37
+ function toolProducesAudio(tool) {
38
+ return (tool.output || []).some((output) => mimeMatches(output, "audio/ogg") || mimeMatches(output, "audio/*"));
39
+ }
40
+
41
+ function looksLikeTextToSpeechTool(tool) {
42
+ return /tts|text.?to.?speech|speech.?audio|speech/i.test(`${tool.name} ${tool.description || ""}`);
43
+ }
44
+
45
+ function selectMediaReplyTool(toolRegistry) {
46
+ const tools = toolRegistry.list()
47
+ .filter(toolSupportsTextInput)
48
+ .filter(toolProducesAudio);
49
+ return tools.find(looksLikeTextToSpeechTool) || tools[0] || null;
50
+ }
51
+
24
52
  export class AgentManager {
25
53
  constructor({ config, artifactStore, toolRegistry, taskStore, logger }) {
26
54
  this.config = config;
@@ -75,7 +103,10 @@ export class AgentManager {
75
103
  model,
76
104
  sessionManager: SessionManager.inMemory(),
77
105
  });
78
- await session.prompt("Reply with exactly: OK");
106
+ await withTimeout(session.prompt("Reply with exactly: OK"), {
107
+ timeoutMs: piValidationTimeoutMs,
108
+ label: "Pi validation prompt"
109
+ });
79
110
  }
80
111
 
81
112
  async getSessionContext(chatId, telegram) {
@@ -117,11 +148,8 @@ export class AgentManager {
117
148
  });
118
149
 
119
150
  if (!hasExistingSession) {
120
- const instructions = await loadProjectInstructions();
121
- const runtimeContext = buildAgentRuntimeContext();
122
- this.logger?.log("agent", `injecting project instructions for chat ${sessionKey}`);
123
- this.logger?.log("agent", `runtime context for chat ${sessionKey}:\n${runtimeContext}`);
124
- await session.prompt(`${instructions}\n\n${runtimeContext}\n\nAcknowledge with exactly: OK`);
151
+ this.logger?.log("agent", `created new session for chat ${sessionKey}`);
152
+ this.logger?.log("agent", `runtime context for chat ${sessionKey}:\n${buildAgentRuntimeContext()}`);
125
153
  }
126
154
 
127
155
  const ctx = { session, modelId: effectiveModelId };
@@ -323,7 +351,20 @@ export class AgentManager {
323
351
  }),
324
352
  execute: async (_id, params) => {
325
353
  await this.toolRegistry.load();
326
- const toolName = params.toolName || "openai-tts";
354
+ const selectedTool = params.toolName
355
+ ? this.toolRegistry.get(params.toolName)
356
+ : selectMediaReplyTool(this.toolRegistry);
357
+ if (!selectedTool) {
358
+ const result = {
359
+ ok: false,
360
+ status: "failed",
361
+ error: params.toolName
362
+ ? `Tool not found: ${params.toolName}`
363
+ : "No registered text-to-speech tool can generate an audio reply."
364
+ };
365
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
366
+ }
367
+ const toolName = selectedTool.name;
327
368
  this.logger?.log("agent", `send_media_reply via ${toolName}`);
328
369
  const result = await this.toolRegistry.run({
329
370
  name: toolName,
@@ -0,0 +1,22 @@
1
+ export class PromptTimeoutError extends Error {
2
+ constructor(label, timeoutMs) {
3
+ super(`${label} timed out after ${timeoutMs}ms`);
4
+ this.name = "PromptTimeoutError";
5
+ this.timeoutMs = timeoutMs;
6
+ }
7
+ }
8
+
9
+ export async function withTimeout(promise, { timeoutMs, label }) {
10
+ let timer = null;
11
+ const timeout = new Promise((_, reject) => {
12
+ timer = setTimeout(() => {
13
+ reject(new PromptTimeoutError(label, timeoutMs));
14
+ }, timeoutMs);
15
+ });
16
+
17
+ try {
18
+ return await Promise.race([promise, timeout]);
19
+ } finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
@@ -0,0 +1,134 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { getToolStateDir, toolStateDir } from "../../runtime/paths.js";
6
+
7
+ export const OWNER_HEARTBEAT_INTERVAL_MS = 2000;
8
+ export const OWNER_HEARTBEAT_TTL_MS = 10000;
9
+
10
+ export function daemonPaths(toolName) {
11
+ const root = getToolStateDir(toolName);
12
+ return {
13
+ root,
14
+ commandsDir: path.join(root, "commands"),
15
+ pidFile: path.join(root, "daemon.pid"),
16
+ metaFile: path.join(root, "daemon.meta.json"),
17
+ statusFile: path.join(root, "status.json"),
18
+ logFile: path.join(root, "daemon.log")
19
+ };
20
+ }
21
+
22
+ export async function readJson(file, fallback = {}) {
23
+ try {
24
+ return JSON.parse(await readFile(file, "utf8"));
25
+ } catch {
26
+ return fallback;
27
+ }
28
+ }
29
+
30
+ export async function writeJson(file, value) {
31
+ await mkdir(path.dirname(file), { recursive: true });
32
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
33
+ }
34
+
35
+ export function isProcessAlive(pid) {
36
+ if (!pid) return false;
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ async function waitForExit(pid, timeoutMs) {
46
+ const startedAt = Date.now();
47
+ while (Date.now() - startedAt < timeoutMs) {
48
+ if (!isProcessAlive(pid)) return true;
49
+ await new Promise((resolve) => setTimeout(resolve, 100));
50
+ }
51
+ return !isProcessAlive(pid);
52
+ }
53
+
54
+ export async function stopManagedDaemon(toolName, { signal = "SIGTERM", forceAfterMs = 3000 } = {}) {
55
+ const paths = daemonPaths(toolName);
56
+ const { pid } = await readJson(paths.pidFile, {});
57
+ if (isProcessAlive(pid)) {
58
+ try {
59
+ process.kill(pid, signal);
60
+ } catch {}
61
+ if (signal !== "SIGKILL" && forceAfterMs > 0 && !(await waitForExit(pid, forceAfterMs))) {
62
+ try {
63
+ process.kill(pid, "SIGKILL");
64
+ } catch {}
65
+ }
66
+ }
67
+ await rm(paths.pidFile, { force: true });
68
+ }
69
+
70
+ export async function startManagedDaemon({ toolName, entryPath, beforeStart = null, ownerEnv = {} }) {
71
+ const paths = daemonPaths(toolName);
72
+ await mkdir(paths.commandsDir, { recursive: true });
73
+
74
+ const current = await readJson(paths.pidFile, {});
75
+ if (isProcessAlive(current.pid)) {
76
+ const sameOwner = !ownerEnv.ARISA_TOOL_OWNER_TOKEN
77
+ || current.ownerToken === ownerEnv.ARISA_TOOL_OWNER_TOKEN;
78
+ if (sameOwner) return current.pid;
79
+ await stopManagedDaemon(toolName);
80
+ }
81
+
82
+ await rm(paths.commandsDir, { recursive: true, force: true });
83
+ await mkdir(paths.commandsDir, { recursive: true });
84
+ if (beforeStart) await beforeStart();
85
+
86
+ const out = openSync(paths.logFile, "a");
87
+ try {
88
+ const child = spawn(process.execPath, [entryPath, "daemon"], {
89
+ detached: false,
90
+ stdio: ["ignore", out, out],
91
+ env: { ...process.env, ...ownerEnv }
92
+ });
93
+ child.unref();
94
+
95
+ const startedAt = new Date().toISOString();
96
+ const record = {
97
+ pid: child.pid,
98
+ startedAt,
99
+ ownerPid: Number.parseInt(ownerEnv.ARISA_OWNER_PID || "", 10) || null,
100
+ ownerToken: ownerEnv.ARISA_TOOL_OWNER_TOKEN || ""
101
+ };
102
+ await writeJson(paths.pidFile, record);
103
+ await writeJson(paths.metaFile, {
104
+ toolName,
105
+ entryPath,
106
+ autoStart: true,
107
+ lastStartedAt: startedAt,
108
+ ownerFile: ownerEnv.ARISA_TOOL_OWNER_FILE || "",
109
+ ownerPid: record.ownerPid,
110
+ ownerToken: record.ownerToken
111
+ });
112
+ return child.pid;
113
+ } finally {
114
+ closeSync(out);
115
+ }
116
+ }
117
+
118
+ export async function listRegisteredDaemons() {
119
+ let entries = [];
120
+ try {
121
+ entries = await readdir(toolStateDir, { withFileTypes: true });
122
+ } catch {
123
+ return [];
124
+ }
125
+
126
+ const records = [];
127
+ for (const entry of entries) {
128
+ if (!entry.isDirectory()) continue;
129
+ const paths = daemonPaths(entry.name);
130
+ const meta = await readJson(paths.metaFile, null);
131
+ if (meta?.toolName && meta?.entryPath) records.push(meta);
132
+ }
133
+ return records;
134
+ }
@@ -1,34 +1,16 @@
1
1
  import crypto from "node:crypto";
2
- import { spawn } from "node:child_process";
3
- import { openSync } from "node:fs";
4
- import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, rename, unlink } from "node:fs/promises";
5
3
  import path from "node:path";
6
- import { getToolStateDir } from "../../runtime/paths.js";
7
-
8
- export function daemonPaths(toolName) {
9
- const root = getToolStateDir(toolName);
10
- return {
11
- root,
12
- commandsDir: path.join(root, "commands"),
13
- pidFile: path.join(root, "daemon.pid"),
14
- statusFile: path.join(root, "status.json"),
15
- logFile: path.join(root, "daemon.log")
16
- };
17
- }
18
-
19
- export async function readJson(file, fallback = {}) {
20
- try { return JSON.parse(await readFile(file, "utf8")); } catch { return fallback; }
21
- }
22
-
23
- export async function writeJson(file, value) {
24
- await mkdir(path.dirname(file), { recursive: true });
25
- await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
26
- }
27
-
28
- export function isProcessAlive(pid) {
29
- if (!pid) return false;
30
- try { process.kill(pid, 0); return true; } catch { return false; }
31
- }
4
+ import {
5
+ OWNER_HEARTBEAT_INTERVAL_MS,
6
+ OWNER_HEARTBEAT_TTL_MS,
7
+ daemonPaths,
8
+ isProcessAlive,
9
+ readJson,
10
+ startManagedDaemon,
11
+ stopManagedDaemon,
12
+ writeJson
13
+ } from "./daemon-processes.js";
32
14
 
33
15
  export function createDaemonRuntime({ toolName, entryPath, beforeStart = null }) {
34
16
  const paths = daemonPaths(toolName);
@@ -54,33 +36,54 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
54
36
  await writeJson(paths.statusFile, { ...current, ...patch, updatedAt: new Date().toISOString() });
55
37
  }
56
38
 
57
- async function clearJobs() {
58
- await rm(paths.commandsDir, { recursive: true, force: true });
59
- await mkdir(paths.commandsDir, { recursive: true });
60
- }
61
-
62
39
  async function start() {
63
- await ensure();
64
- const pid = await getPid();
65
- if (isProcessAlive(pid)) return pid;
66
-
67
- await clearJobs();
68
- if (beforeStart) await beforeStart();
69
- const out = openSync(paths.logFile, "a");
70
- const child = spawn(process.execPath, [entryPath, "daemon"], {
71
- detached: true,
72
- stdio: ["ignore", out, out],
73
- env: process.env
40
+ return startManagedDaemon({
41
+ toolName,
42
+ entryPath,
43
+ beforeStart,
44
+ ownerEnv: {
45
+ ARISA_OWNER_PID: process.env.ARISA_OWNER_PID,
46
+ ARISA_TOOL_OWNER_FILE: process.env.ARISA_TOOL_OWNER_FILE,
47
+ ARISA_TOOL_OWNER_TOKEN: process.env.ARISA_TOOL_OWNER_TOKEN
48
+ }
74
49
  });
75
- child.unref();
76
- await writeJson(paths.pidFile, { pid: child.pid, startedAt: new Date().toISOString() });
77
- return child.pid;
78
50
  }
79
51
 
80
52
  async function stop() {
81
- const pid = await getPid();
82
- if (isProcessAlive(pid)) process.kill(pid, "SIGTERM");
83
- await rm(paths.pidFile, { force: true });
53
+ await stopManagedDaemon(toolName);
54
+ }
55
+
56
+ function installOwnerWatch() {
57
+ const ownerFile = process.env.ARISA_TOOL_OWNER_FILE;
58
+ const ownerToken = process.env.ARISA_TOOL_OWNER_TOKEN;
59
+ if (!ownerFile || !ownerToken) return;
60
+
61
+ let exiting = false;
62
+ async function exitIfOrphaned(message) {
63
+ if (exiting) return;
64
+ exiting = true;
65
+ await writeStatus({ state: "stopped", message });
66
+ process.exit(0);
67
+ }
68
+
69
+ const timer = setInterval(async () => {
70
+ const owner = await readJson(ownerFile, null);
71
+ if (!owner || owner.token !== ownerToken) {
72
+ await exitIfOrphaned("Arisa owner stopped");
73
+ return;
74
+ }
75
+
76
+ const heartbeatAt = Date.parse(owner.heartbeatAt || "");
77
+ if (!Number.isFinite(heartbeatAt) || Date.now() - heartbeatAt > OWNER_HEARTBEAT_TTL_MS) {
78
+ await exitIfOrphaned("Arisa owner heartbeat expired");
79
+ return;
80
+ }
81
+
82
+ if (!isProcessAlive(owner.pid)) {
83
+ await exitIfOrphaned("Arisa owner process exited");
84
+ }
85
+ }, OWNER_HEARTBEAT_INTERVAL_MS);
86
+ timer.unref();
84
87
  }
85
88
 
86
89
  async function waitReady({ timeoutMs = 120000, readyStates = ["ready"] } = {}) {
@@ -140,6 +143,7 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
140
143
  }
141
144
 
142
145
  async function workLoop({ processJob, idleTimeoutMs = 0, intervalMs = 250 }) {
146
+ installOwnerWatch();
143
147
  let lastActivity = Date.now();
144
148
  setInterval(async () => {
145
149
  try {
@@ -25,8 +25,9 @@ function runProcess(command, args, options = {}) {
25
25
  }
26
26
 
27
27
  export class ToolRegistry {
28
- constructor({ logger } = {}) {
28
+ constructor({ logger, processOwnerEnv = {} } = {}) {
29
29
  this.logger = logger;
30
+ this.processOwnerEnv = processOwnerEnv;
30
31
  this.tools = new Map();
31
32
  this.skillRegistry = new SkillRegistry();
32
33
  }
@@ -153,7 +154,7 @@ export class ToolRegistry {
153
154
  await writeFile(requestFile, `${JSON.stringify(enrichedRequest, null, 2)}\n`, "utf8");
154
155
  const result = await runProcess("node", [tool.entry, "run", "--request-file", requestFile], {
155
156
  cwd: tool.dir,
156
- env: process.env
157
+ env: { ...process.env, ...this.processOwnerEnv }
157
158
  });
158
159
  await unlink(requestFile).catch(() => {});
159
160
  await rmdir(tmpDir).catch(() => {});
package/src/index.js CHANGED
@@ -4,7 +4,7 @@ import { createServer } from "node:http";
4
4
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
5
5
  import { createApp } from "./runtime/create-app.js";
6
6
  import { createLogger } from "./runtime/logger.js";
7
- import { getServiceStatus, registerServiceProcess, startService, stopService } from "./runtime/service-manager.js";
7
+ import { getServiceStatus, registerServiceProcess, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
8
8
  import { flushArisaHome } from "./runtime/flush.js";
9
9
  import { installPiPackage, removePiPackage } from "./runtime/pi-package-manager.js";
10
10
 
@@ -17,6 +17,8 @@ const serviceRunner = Boolean(cli.flags["service-runner"]);
17
17
  const bootstrapOverrides = toBootstrapOverrides(cli.nestedFlags);
18
18
  const runtimeOverrides = toRuntimeOverrides(cli.nestedFlags);
19
19
  const logger = createLogger({ verbose });
20
+ let activeApp = null;
21
+ let shuttingDown = false;
20
22
 
21
23
  const httpPort = Number(process.env.PORT);
22
24
  let httpRequestHandler = null;
@@ -97,6 +99,35 @@ const bootstrapHttpOptions = httpPort ? { httpPort, setHttpRequestHandler } : {}
97
99
  const webhookUrl = bootstrapOverrides.webhook?.url || "";
98
100
  const appHttpOptions = httpPort ? { webhookUrl, setHttpRequestHandler } : {};
99
101
 
102
+ async function shutdown(exitCode = 0) {
103
+ if (shuttingDown) return;
104
+ shuttingDown = true;
105
+ try {
106
+ await activeApp?.stop?.();
107
+ } catch (error) {
108
+ logger.error("app", `shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
109
+ exitCode = exitCode || 1;
110
+ }
111
+ if (serviceRunner) {
112
+ await unregisterServiceProcess();
113
+ }
114
+ process.exit(exitCode);
115
+ }
116
+
117
+ process.once("SIGTERM", () => {
118
+ shutdown(0);
119
+ });
120
+
121
+ process.once("SIGINT", () => {
122
+ shutdown(0);
123
+ });
124
+
125
+ async function startRuntimeApp() {
126
+ const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
127
+ activeApp = app;
128
+ await app.start();
129
+ }
130
+
100
131
  async function runForeground() {
101
132
  const hasRuntimePiOverrides = Boolean(
102
133
  runtimeOverrides?.pi?.model
@@ -106,11 +137,12 @@ async function runForeground() {
106
137
  logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
107
138
  await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
108
139
  try {
109
- const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
110
- await app.start();
140
+ await startRuntimeApp();
111
141
  } catch (error) {
112
142
  const message = error instanceof Error ? error.message : String(error);
113
143
  if (message.includes("No auth found")) {
144
+ await activeApp?.stop?.();
145
+ activeApp = null;
114
146
  console.log(`\n${message}\n`);
115
147
  if (hasRuntimePiOverrides) {
116
148
  console.log("Skipping automatic bootstrap because Pi runtime overrides were provided.");
@@ -119,8 +151,7 @@ async function runForeground() {
119
151
  }
120
152
  console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
121
153
  await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
122
- const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
123
- await app.start();
154
+ await startRuntimeApp();
124
155
  return;
125
156
  }
126
157
  throw error;
@@ -202,4 +233,9 @@ async function main() {
202
233
  await runForeground();
203
234
  }
204
235
 
205
- await main();
236
+ try {
237
+ await main();
238
+ } catch (error) {
239
+ logger.error("app", error instanceof Error ? error.message : String(error));
240
+ await shutdown(1);
241
+ }
@@ -4,6 +4,7 @@ import { ToolRegistry } from "../core/tools/tool-registry.js";
4
4
  import { TaskStore } from "../core/tasks/task-store.js";
5
5
  import { AgentManager } from "../core/agent/agent-manager.js";
6
6
  import { createTelegramBot } from "../transport/telegram/bot.js";
7
+ import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
7
8
 
8
9
  function normalizeString(value) {
9
10
  const text = String(value ?? "").trim();
@@ -51,7 +52,8 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
51
52
  }
52
53
 
53
54
  const artifactStore = new ArtifactStore();
54
- const toolRegistry = new ToolRegistry({ logger });
55
+ const toolProcessSupervisor = createToolProcessSupervisor({ logger });
56
+ const toolRegistry = new ToolRegistry({ logger, processOwnerEnv: toolProcessSupervisor.env() });
55
57
  const taskStore = new TaskStore();
56
58
  await toolRegistry.load();
57
59
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
@@ -63,8 +65,19 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
63
65
  async start() {
64
66
  logger?.log("app", `validating Pi model ${config.pi.provider}/${config.pi.model}`);
65
67
  await agentManager.validatePiAgent();
68
+ await toolProcessSupervisor.start();
66
69
  logger?.log("app", "starting Telegram bot");
67
- await bot.start();
70
+ try {
71
+ await bot.start();
72
+ } catch (error) {
73
+ await toolProcessSupervisor.stop();
74
+ throw error;
75
+ }
76
+ },
77
+
78
+ async stop() {
79
+ await bot.stop?.();
80
+ await toolProcessSupervisor.stop();
68
81
  }
69
82
  };
70
83
  }
@@ -7,6 +7,7 @@ export const stateDir = path.join(arisaHomeDir, "state");
7
7
  export const configFile = path.join(stateDir, "config.json");
8
8
  export const servicePidFile = path.join(stateDir, "arisa.pid");
9
9
  export const serviceLogFile = path.join(stateDir, "arisa.log");
10
+ export const toolDaemonOwnerFile = path.join(stateDir, "tool-daemon-owner.json");
10
11
  export const tasksFile = path.join(stateDir, "tasks.json");
11
12
  export const toolsDir = path.join(arisaHomeDir, "tools");
12
13
  export const chatsDir = path.join(arisaHomeDir, "chats");
@@ -74,24 +74,14 @@ export async function stopService() {
74
74
  return { ok: true, pid: status.pid };
75
75
  }
76
76
 
77
+ export async function unregisterServiceProcess() {
78
+ await rm(servicePidFile, { force: true }).catch(() => {});
79
+ }
80
+
77
81
  export async function registerServiceProcess() {
78
82
  await ensureArisaHome();
79
83
  await writeFile(servicePidFile, `${process.pid}\n`, "utf8");
80
84
 
81
- const cleanup = async () => {
82
- await rm(servicePidFile, { force: true }).catch(() => {});
83
- };
84
-
85
- process.on("SIGTERM", async () => {
86
- await cleanup();
87
- process.exit(0);
88
- });
89
-
90
- process.on("SIGINT", async () => {
91
- await cleanup();
92
- process.exit(0);
93
- });
94
-
95
85
  process.on("exit", () => {
96
86
  rm(servicePidFile, { force: true }).catch(() => {});
97
87
  });
@@ -0,0 +1,110 @@
1
+ import crypto from "node:crypto";
2
+ import { access, rm } from "node:fs/promises";
3
+ import {
4
+ daemonPaths,
5
+ listRegisteredDaemons,
6
+ readJson,
7
+ startManagedDaemon,
8
+ stopManagedDaemon,
9
+ writeJson
10
+ } from "../core/tools/daemon-processes.js";
11
+ import { ensureArisaHome, toolDaemonOwnerFile } from "./paths.js";
12
+
13
+ async function fileExists(file) {
14
+ try {
15
+ await access(file);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export function createToolProcessSupervisor({ logger } = {}) {
23
+ const token = crypto.randomUUID();
24
+ let heartbeatTimer = null;
25
+ let running = false;
26
+
27
+ const ownerEnv = {
28
+ ARISA_OWNER_PID: String(process.pid),
29
+ ARISA_TOOL_OWNER_FILE: toolDaemonOwnerFile,
30
+ ARISA_TOOL_OWNER_TOKEN: token
31
+ };
32
+
33
+ async function writeHeartbeat() {
34
+ await writeJson(toolDaemonOwnerFile, {
35
+ pid: process.pid,
36
+ token,
37
+ heartbeatAt: new Date().toISOString()
38
+ });
39
+ }
40
+
41
+ async function startHeartbeat() {
42
+ await ensureArisaHome();
43
+ await writeHeartbeat();
44
+ heartbeatTimer = setInterval(() => {
45
+ writeHeartbeat().catch((error) => {
46
+ logger?.error("tools", `tool daemon heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
47
+ });
48
+ }, 2000);
49
+ heartbeatTimer.unref();
50
+ }
51
+
52
+ async function restartRegisteredDaemons() {
53
+ for (const record of await listRegisteredDaemons()) {
54
+ if (!record.autoStart) continue;
55
+ if (!(await fileExists(record.entryPath))) {
56
+ logger?.log("tools", `skipping daemon ${record.toolName}: missing entry ${record.entryPath}`);
57
+ continue;
58
+ }
59
+
60
+ const paths = daemonPaths(record.toolName);
61
+ const { pid } = await readJson(paths.pidFile, {});
62
+ if (pid) {
63
+ await stopManagedDaemon(record.toolName);
64
+ }
65
+
66
+ logger?.log("tools", `starting managed daemon ${record.toolName}`);
67
+ await startManagedDaemon({
68
+ toolName: record.toolName,
69
+ entryPath: record.entryPath,
70
+ ownerEnv
71
+ });
72
+ }
73
+ }
74
+
75
+ async function stopRegisteredDaemons() {
76
+ for (const record of await listRegisteredDaemons()) {
77
+ logger?.log("tools", `stopping managed daemon ${record.toolName}`);
78
+ await stopManagedDaemon(record.toolName);
79
+ }
80
+ }
81
+
82
+ async function removeOwnerFile() {
83
+ const current = await readJson(toolDaemonOwnerFile, null);
84
+ if (current?.token === token) {
85
+ await rm(toolDaemonOwnerFile, { force: true });
86
+ }
87
+ }
88
+
89
+ return {
90
+ env() {
91
+ return ownerEnv;
92
+ },
93
+
94
+ async start() {
95
+ if (running) return;
96
+ running = true;
97
+ await startHeartbeat();
98
+ await restartRegisteredDaemons();
99
+ },
100
+
101
+ async stop() {
102
+ if (!running) return;
103
+ running = false;
104
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
105
+ heartbeatTimer = null;
106
+ await stopRegisteredDaemons();
107
+ await removeOwnerFile();
108
+ }
109
+ };
110
+ }
@@ -3,8 +3,11 @@ import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
4
  import { captureIncomingArtifact } from "./media.js";
5
5
  import { renderTelegramHtml } from "./text-format.js";
6
+ import { withTimeout } from "../../core/agent/prompt-timeout.js";
6
7
  import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
7
8
 
9
+ const promptTimeoutMs = 300_000;
10
+
8
11
  function quotedMessageSummary(message) {
9
12
  if (!message) return [];
10
13
 
@@ -154,15 +157,52 @@ async function normalizeIncomingArtifact({ artifact, toolRegistry, chatArtifactS
154
157
  return { transcript: normalizedArtifact, toolResult };
155
158
  }
156
159
 
157
- async function collectText(session, prompt) {
160
+ function sessionEventLogMessage(event) {
161
+ if (event.type === "tool_execution_start") {
162
+ return `tool ${event.toolName} started`;
163
+ }
164
+ if (event.type === "tool_execution_end") {
165
+ return `tool ${event.toolName} ${event.isError ? "failed" : "finished"}`;
166
+ }
167
+ if (event.type === "auto_retry_start") {
168
+ return `auto retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms: ${event.errorMessage}`;
169
+ }
170
+ if (event.type === "auto_retry_end") {
171
+ return event.success
172
+ ? `auto retry succeeded after ${event.attempt} attempt(s)`
173
+ : `auto retry failed after ${event.attempt} attempt(s): ${event.finalError || "unknown error"}`;
174
+ }
175
+ if (event.type === "compaction_start") {
176
+ return `compaction started (${event.reason})`;
177
+ }
178
+ if (event.type === "compaction_end") {
179
+ return `compaction ${event.aborted ? "aborted" : "finished"} (${event.reason})`;
180
+ }
181
+ if (event.type === "message_end" && event.message?.stopReason === "error") {
182
+ return `assistant message ended with error: ${event.message.errorMessage || "unknown error"}`;
183
+ }
184
+ return "";
185
+ }
186
+
187
+ async function collectText(session, prompt, { logger, chatId } = {}) {
158
188
  let text = "";
159
189
  const unsubscribe = session.subscribe((event) => {
160
190
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
161
191
  text += event.assistantMessageEvent.delta;
162
192
  }
193
+ const logMessage = sessionEventLogMessage(event);
194
+ if (logMessage) logger?.log("agent", `chat ${chatId} ${logMessage}`);
163
195
  });
164
- await session.prompt(prompt);
165
- unsubscribe();
196
+
197
+ try {
198
+ await withTimeout(session.prompt(prompt), {
199
+ timeoutMs: promptTimeoutMs,
200
+ label: `Telegram prompt for chat ${chatId}`
201
+ });
202
+ } finally {
203
+ unsubscribe();
204
+ }
205
+
166
206
  return text.trim();
167
207
  }
168
208
 
@@ -182,6 +222,7 @@ async function withTyping(ctx, work) {
182
222
  export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler }) {
183
223
  const bot = new Bot(config.telegram.token);
184
224
  const perChatState = new Map();
225
+ let taskTimer = null;
185
226
 
186
227
  function getIncomingChatMeta(ctx) {
187
228
  return {
@@ -252,7 +293,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
252
293
  async function processPromptForChat({ chatId, prompt, ctx = null }) {
253
294
  const work = async () => {
254
295
  const { session } = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
255
- const text = await collectText(session, prompt);
296
+ let text = "";
297
+ try {
298
+ text = await collectText(session, prompt, { logger, chatId });
299
+ } catch (error) {
300
+ agentManager.resetSession(chatId);
301
+ throw error;
302
+ }
256
303
  if (text) {
257
304
  await sendTextReply({
258
305
  sendText: (message, extra) => bot.api.sendMessage(chatId, message, extra),
@@ -283,12 +330,19 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
283
330
  let currentPrompt = prompt;
284
331
  let currentCtx = ctx;
285
332
 
286
- while (currentPrompt) {
287
- try {
288
- logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
289
- await processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx });
290
- } finally {
291
- currentCtx = null;
333
+ try {
334
+ while (currentPrompt) {
335
+ try {
336
+ logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
337
+ await processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx });
338
+ } catch (error) {
339
+ const message = error instanceof Error ? error.message : String(error);
340
+ logger?.error("telegram", `${label} failed for chat ${chatId}: ${message}`);
341
+ throw error;
342
+ } finally {
343
+ currentCtx = null;
344
+ }
345
+
292
346
  if (chatState.nextPrompt) {
293
347
  currentPrompt = chatState.nextPrompt;
294
348
  chatState.nextPrompt = "";
@@ -296,9 +350,9 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
296
350
  currentPrompt = "";
297
351
  }
298
352
  }
353
+ } finally {
354
+ chatState.processing = false;
299
355
  }
300
-
301
- chatState.processing = false;
302
356
  }
303
357
 
304
358
  async function enqueueOrProcess(ctx) {
@@ -460,11 +514,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
460
514
  await bot.api.setMyCommands([
461
515
  { command: "new", description: "Start a new chat context" }
462
516
  ]);
463
- setInterval(() => {
464
- dispatchDueTasks().catch((error) => {
465
- logger?.error("tasks", `dispatch failed: ${error instanceof Error ? error.message : String(error)}`);
466
- });
467
- }, 1000).unref();
517
+ if (!taskTimer) {
518
+ taskTimer = setInterval(() => {
519
+ dispatchDueTasks().catch((error) => {
520
+ logger?.error("tasks", `dispatch failed: ${error instanceof Error ? error.message : String(error)}`);
521
+ });
522
+ }, 1000);
523
+ taskTimer.unref();
524
+ }
468
525
  if (webhookUrl && setHttpRequestHandler) {
469
526
  const webhookPath = `/telegram-${config.telegram.token.slice(-8)}`;
470
527
  const handleUpdate = webhookCallback(bot, "http", {
@@ -485,6 +542,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
485
542
  logger?.log("telegram", "bot polling started");
486
543
  await bot.start({ drop_pending_updates: true });
487
544
  }
545
+ },
546
+
547
+ async stop() {
548
+ if (taskTimer) clearInterval(taskTimer);
549
+ taskTimer = null;
550
+ try {
551
+ bot.stop();
552
+ } catch {}
488
553
  }
489
554
  };
490
555
  }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import defaults from "./config.js";
4
4
  import { loadToolConfig } from "../../src/core/tools/tool-config.js";
5
5
  import { toolError, toolNeedsConfig, toolOk } from "../../src/core/tools/tool-result.js";
6
- import { getToolConfigPath, getToolOutDir } from "../../src/runtime/paths.js";
6
+ import { getChatToolTmpDir, getToolConfigPath, getToolTmpDir } from "../../src/runtime/paths.js";
7
7
 
8
8
  const toolName = "openai-tts";
9
9
  const config = await loadToolConfig(toolName, defaults);
@@ -49,7 +49,9 @@ async function run(requestFile) {
49
49
  return;
50
50
  }
51
51
 
52
- const outDir = getToolOutDir(toolName);
52
+ const outDir = request.chatId != null
53
+ ? getChatToolTmpDir(request.chatId, toolName)
54
+ : getToolTmpDir(toolName);
53
55
  await mkdir(outDir, { recursive: true });
54
56
  const filePath = path.join(outDir, `speech-${Date.now()}.ogg`);
55
57
  const buffer = Buffer.from(await response.arrayBuffer());
@@ -1,11 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { fileURLToPath } from "node:url";
3
-
4
- const instructionsPath = fileURLToPath(new URL("../../../AGENTS.md", import.meta.url));
5
- let cachedInstructions = null;
6
-
7
- export async function loadProjectInstructions() {
8
- if (cachedInstructions !== null) return cachedInstructions;
9
- cachedInstructions = await readFile(instructionsPath, "utf8");
10
- return cachedInstructions;
11
- }