@dotsetlabs/dotclaw 1.5.2 → 1.7.0

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 (111) hide show
  1. package/README.md +8 -3
  2. package/config-examples/runtime.json +10 -8
  3. package/config-examples/tool-policy.json +17 -0
  4. package/container/Dockerfile +4 -0
  5. package/container/agent-runner/package-lock.json +2 -2
  6. package/container/agent-runner/package.json +1 -1
  7. package/container/agent-runner/src/agent-config.ts +17 -15
  8. package/container/agent-runner/src/container-protocol.ts +10 -6
  9. package/container/agent-runner/src/daemon.ts +237 -32
  10. package/container/agent-runner/src/heartbeat-worker.ts +94 -0
  11. package/container/agent-runner/src/id.ts +4 -0
  12. package/container/agent-runner/src/index.ts +356 -188
  13. package/container/agent-runner/src/ipc.ts +161 -12
  14. package/container/agent-runner/src/memory.ts +8 -2
  15. package/container/agent-runner/src/prompt-packs.ts +5 -209
  16. package/container/agent-runner/src/request-worker.ts +25 -0
  17. package/container/agent-runner/src/tools.ts +423 -32
  18. package/dist/agent-context.d.ts +1 -0
  19. package/dist/agent-context.d.ts.map +1 -1
  20. package/dist/agent-context.js +15 -7
  21. package/dist/agent-context.js.map +1 -1
  22. package/dist/agent-execution.d.ts +11 -6
  23. package/dist/agent-execution.d.ts.map +1 -1
  24. package/dist/agent-execution.js +6 -4
  25. package/dist/agent-execution.js.map +1 -1
  26. package/dist/background-jobs.d.ts +9 -0
  27. package/dist/background-jobs.d.ts.map +1 -1
  28. package/dist/background-jobs.js +70 -15
  29. package/dist/background-jobs.js.map +1 -1
  30. package/dist/behavior-config.d.ts +0 -1
  31. package/dist/behavior-config.d.ts.map +1 -1
  32. package/dist/behavior-config.js +0 -3
  33. package/dist/behavior-config.js.map +1 -1
  34. package/dist/cli.js +313 -45
  35. package/dist/cli.js.map +1 -1
  36. package/dist/config.d.ts +6 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +6 -0
  39. package/dist/config.js.map +1 -1
  40. package/dist/container-protocol.d.ts +10 -6
  41. package/dist/container-protocol.d.ts.map +1 -1
  42. package/dist/container-runner.d.ts +28 -8
  43. package/dist/container-runner.d.ts.map +1 -1
  44. package/dist/container-runner.js +254 -54
  45. package/dist/container-runner.js.map +1 -1
  46. package/dist/dashboard.js +1 -1
  47. package/dist/dashboard.js.map +1 -1
  48. package/dist/db.d.ts +31 -3
  49. package/dist/db.d.ts.map +1 -1
  50. package/dist/db.js +277 -39
  51. package/dist/db.js.map +1 -1
  52. package/dist/id.d.ts +2 -0
  53. package/dist/id.d.ts.map +1 -0
  54. package/dist/id.js +4 -0
  55. package/dist/id.js.map +1 -0
  56. package/dist/index.js +1194 -381
  57. package/dist/index.js.map +1 -1
  58. package/dist/json-helpers.d.ts +1 -0
  59. package/dist/json-helpers.d.ts.map +1 -1
  60. package/dist/json-helpers.js +33 -1
  61. package/dist/json-helpers.js.map +1 -1
  62. package/dist/maintenance.d.ts +2 -0
  63. package/dist/maintenance.d.ts.map +1 -1
  64. package/dist/maintenance.js +196 -17
  65. package/dist/maintenance.js.map +1 -1
  66. package/dist/memory-embeddings.d.ts +1 -0
  67. package/dist/memory-embeddings.d.ts.map +1 -1
  68. package/dist/memory-embeddings.js +38 -4
  69. package/dist/memory-embeddings.js.map +1 -1
  70. package/dist/memory-recall.d.ts.map +1 -1
  71. package/dist/memory-recall.js +6 -1
  72. package/dist/memory-recall.js.map +1 -1
  73. package/dist/memory-store.d.ts +1 -0
  74. package/dist/memory-store.d.ts.map +1 -1
  75. package/dist/memory-store.js +72 -10
  76. package/dist/memory-store.js.map +1 -1
  77. package/dist/metrics.d.ts +1 -0
  78. package/dist/metrics.d.ts.map +1 -1
  79. package/dist/metrics.js +16 -2
  80. package/dist/metrics.js.map +1 -1
  81. package/dist/path-mapping.d.ts +4 -0
  82. package/dist/path-mapping.d.ts.map +1 -0
  83. package/dist/path-mapping.js +50 -0
  84. package/dist/path-mapping.js.map +1 -0
  85. package/dist/paths.d.ts +4 -2
  86. package/dist/paths.d.ts.map +1 -1
  87. package/dist/paths.js +4 -2
  88. package/dist/paths.js.map +1 -1
  89. package/dist/personalization.d.ts +1 -0
  90. package/dist/personalization.d.ts.map +1 -1
  91. package/dist/personalization.js +14 -0
  92. package/dist/personalization.js.map +1 -1
  93. package/dist/progress.d.ts +2 -2
  94. package/dist/progress.d.ts.map +1 -1
  95. package/dist/progress.js +18 -14
  96. package/dist/progress.js.map +1 -1
  97. package/dist/runtime-config.d.ts +18 -7
  98. package/dist/runtime-config.d.ts.map +1 -1
  99. package/dist/runtime-config.js +29 -18
  100. package/dist/runtime-config.js.map +1 -1
  101. package/dist/task-scheduler.d.ts +7 -1
  102. package/dist/task-scheduler.d.ts.map +1 -1
  103. package/dist/task-scheduler.js +182 -48
  104. package/dist/task-scheduler.js.map +1 -1
  105. package/dist/timezone.d.ts +4 -0
  106. package/dist/timezone.d.ts.map +1 -0
  107. package/dist/timezone.js +111 -0
  108. package/dist/timezone.js.map +1 -0
  109. package/dist/types.d.ts +31 -0
  110. package/dist/types.d.ts.map +1 -1
  111. package/package.json +6 -1
package/README.md CHANGED
@@ -6,6 +6,8 @@ Personal OpenRouter-based assistant for Telegram. Each request runs inside an is
6
6
 
7
7
  - Telegram bot interface with per-group isolation
8
8
  - Containerized agent runtime with strict mounts
9
+ - Rich Telegram I/O tools (file/photo/voice/audio/location/contact/poll/buttons/edit/delete)
10
+ - Incoming media ingestion to workspace (`/workspace/group/inbox`) for agent processing
9
11
  - Long-term memory with embeddings and semantic search
10
12
  - Scheduled tasks (cron and one-off)
11
13
  - Background jobs for long-running work
@@ -45,9 +47,6 @@ After installation, use the `dotclaw` CLI:
45
47
  ```bash
46
48
  dotclaw setup # Full setup (init + configure + build + install service)
47
49
  dotclaw configure # Re-configure API keys and model
48
- dotclaw add-instance # Create and start an isolated instance
49
- dotclaw instances # List discovered instances
50
- dotclaw build # Build the Docker container image
51
50
  dotclaw start # Start the service
52
51
  dotclaw stop # Stop the service
53
52
  dotclaw restart # Restart the service
@@ -55,6 +54,12 @@ dotclaw logs # View logs (add --follow to tail)
55
54
  dotclaw status # Show service status
56
55
  dotclaw doctor # Run diagnostics
57
56
  dotclaw register # Register a new Telegram chat
57
+ dotclaw unregister # Remove a registered Telegram chat
58
+ dotclaw groups # List registered Telegram chats
59
+ dotclaw build # Build the Docker container image
60
+ dotclaw add-instance # Create and start an isolated instance
61
+ dotclaw instances # List discovered instances
62
+ dotclaw version # Show installed version
58
63
  ```
59
64
 
60
65
  Instance flags:
@@ -3,6 +3,7 @@
3
3
  "logLevel": "info",
4
4
  "container": {
5
5
  "mode": "daemon",
6
+ "privileged": true,
6
7
  "instanceId": ""
7
8
  },
8
9
  "metrics": {
@@ -10,7 +11,8 @@
10
11
  "enabled": true
11
12
  },
12
13
  "dashboard": {
13
- "enabled": true
14
+ "enabled": true,
15
+ "port": 3002
14
16
  },
15
17
  "memory": {
16
18
  "embeddings": {
@@ -54,9 +56,9 @@
54
56
  "maxFastChars": 200,
55
57
  "maxStandardChars": 1200,
56
58
  "backgroundMinChars": 2000,
57
- "fastKeywords": ["hi", "hello", "hey", "who are you", "what can you do"],
58
- "deepKeywords": ["research", "analysis", "report", "dashboard", "refactor", "architecture", "design"],
59
- "backgroundKeywords": ["background", "long-running", "research", "dashboard", "refactor", "report"],
59
+ "fastKeywords": ["hi", "hello", "hey", "who are you", "what can you do", "help", "thanks", "thank you"],
60
+ "deepKeywords": ["research", "analysis", "analyze", "report", "dashboard", "vibe", "refactor", "architecture", "design", "spec", "strategy", "migration", "benchmark", "investigate", "evaluate", "summarize", "long-running"],
61
+ "backgroundKeywords": ["background", "long-running", "research", "deep dive", "multi-step", "multi step", "dashboard", "refactor", "benchmark", "report", "analysis", "survey", "crawl", "scrape", "codebase"],
60
62
  "classifierFallback": {
61
63
  "enabled": true,
62
64
  "minChars": 600
@@ -74,7 +76,7 @@
74
76
  "profiles": {
75
77
  "fast": {
76
78
  "model": "openai/gpt-5-nano",
77
- "maxOutputTokens": 256,
79
+ "maxOutputTokens": 4096,
78
80
  "maxToolSteps": 6,
79
81
  "recallMaxResults": 0,
80
82
  "recallMaxTokens": 0,
@@ -87,7 +89,7 @@
87
89
  },
88
90
  "standard": {
89
91
  "model": "openai/gpt-5-mini",
90
- "maxOutputTokens": 768,
92
+ "maxOutputTokens": 4096,
91
93
  "maxToolSteps": 16,
92
94
  "recallMaxResults": 6,
93
95
  "recallMaxTokens": 1500,
@@ -99,7 +101,7 @@
99
101
  },
100
102
  "deep": {
101
103
  "model": "moonshotai/kimi-k2.5",
102
- "maxOutputTokens": 1536,
104
+ "maxOutputTokens": 4096,
103
105
  "maxToolSteps": 32,
104
106
  "recallMaxResults": 12,
105
107
  "recallMaxTokens": 2500,
@@ -111,7 +113,7 @@
111
113
  },
112
114
  "background": {
113
115
  "model": "moonshotai/kimi-k2.5",
114
- "maxOutputTokens": 2048,
116
+ "maxOutputTokens": 4096,
115
117
  "maxToolSteps": 64,
116
118
  "recallMaxResults": 16,
117
119
  "recallMaxTokens": 4000,
@@ -13,12 +13,29 @@
13
13
  "Bash",
14
14
  "Python",
15
15
  "mcp__dotclaw__send_message",
16
+ "mcp__dotclaw__send_file",
17
+ "mcp__dotclaw__send_photo",
18
+ "mcp__dotclaw__send_voice",
19
+ "mcp__dotclaw__send_audio",
20
+ "mcp__dotclaw__send_location",
21
+ "mcp__dotclaw__send_contact",
22
+ "mcp__dotclaw__send_poll",
23
+ "mcp__dotclaw__send_buttons",
24
+ "mcp__dotclaw__edit_message",
25
+ "mcp__dotclaw__delete_message",
26
+ "mcp__dotclaw__download_url",
16
27
  "mcp__dotclaw__schedule_task",
28
+ "mcp__dotclaw__run_task",
17
29
  "mcp__dotclaw__list_tasks",
18
30
  "mcp__dotclaw__pause_task",
19
31
  "mcp__dotclaw__resume_task",
20
32
  "mcp__dotclaw__cancel_task",
21
33
  "mcp__dotclaw__update_task",
34
+ "mcp__dotclaw__spawn_job",
35
+ "mcp__dotclaw__job_status",
36
+ "mcp__dotclaw__list_jobs",
37
+ "mcp__dotclaw__cancel_job",
38
+ "mcp__dotclaw__job_update",
22
39
  "mcp__dotclaw__register_group",
23
40
  "mcp__dotclaw__remove_group",
24
41
  "mcp__dotclaw__list_groups",
@@ -23,11 +23,15 @@ RUN apt-get update && apt-get install -y \
23
23
  libxshmfence1 \
24
24
  curl \
25
25
  git \
26
+ sudo \
26
27
  python3 \
27
28
  python3-pip \
28
29
  python3-venv \
29
30
  && rm -rf /var/lib/apt/lists/*
30
31
 
32
+ # Allow the node user to install system packages via sudo
33
+ RUN echo 'node ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node
34
+
31
35
  # Install common Python packages
32
36
  RUN pip3 install --break-system-packages pandas numpy requests beautifulsoup4 matplotlib
33
37
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dotclaw-agent-runner",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "dotclaw-agent-runner",
9
- "version": "1.5.2",
9
+ "version": "1.6.0",
10
10
  "dependencies": {
11
11
  "@openrouter/sdk": "^0.3.0",
12
12
  "cron-parser": "^5.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotclaw-agent-runner",
3
- "version": "1.5.2",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Container-side agent runner for DotClaw",
6
6
  "main": "dist/index.js",
@@ -3,6 +3,7 @@ import fs from 'fs';
3
3
  export type AgentRuntimeConfig = {
4
4
  defaultModel: string;
5
5
  daemonPollMs: number;
6
+ daemonHeartbeatIntervalMs: number;
6
7
  agent: {
7
8
  assistantName: string;
8
9
  openrouter: {
@@ -103,10 +104,6 @@ export type AgentRuntimeConfig = {
103
104
  tools: string[];
104
105
  };
105
106
  };
106
- streaming: {
107
- minIntervalMs: number;
108
- minChars: number;
109
- };
110
107
  ipc: {
111
108
  requestTimeoutMs: number;
112
109
  requestPollMs: number;
@@ -122,6 +119,7 @@ export type AgentRuntimeConfig = {
122
119
  const CONFIG_PATH = '/workspace/config/runtime.json';
123
120
  const DEFAULT_DEFAULT_MODEL = 'moonshotai/kimi-k2.5';
124
121
  const DEFAULT_DAEMON_POLL_MS = 200;
122
+ const DEFAULT_DAEMON_HEARTBEAT_INTERVAL_MS = 1_000;
125
123
 
126
124
  const DEFAULT_AGENT_CONFIG: AgentRuntimeConfig['agent'] = {
127
125
  assistantName: 'Rain',
@@ -143,7 +141,7 @@ const DEFAULT_AGENT_CONFIG: AgentRuntimeConfig['agent'] = {
143
141
  recentContextTokens: 8000,
144
142
  summaryUpdateEveryMessages: 20,
145
143
  maxOutputTokens: 1024,
146
- summaryMaxOutputTokens: 600,
144
+ summaryMaxOutputTokens: 2048,
147
145
  temperature: 0.2,
148
146
  maxContextMessageTokens: 3000
149
147
  },
@@ -154,7 +152,7 @@ const DEFAULT_AGENT_CONFIG: AgentRuntimeConfig['agent'] = {
154
152
  enabled: true,
155
153
  async: true,
156
154
  maxMessages: 4,
157
- maxOutputTokens: 200
155
+ maxOutputTokens: 1024
158
156
  },
159
157
  archiveSync: true,
160
158
  extractScheduled: false
@@ -171,12 +169,12 @@ const DEFAULT_AGENT_CONFIG: AgentRuntimeConfig['agent'] = {
171
169
  mode: 'auto',
172
170
  minTokens: 800,
173
171
  triggerRegex: '(plan|steps|roadmap|research|design|architecture|spec|strategy)',
174
- maxOutputTokens: 200,
172
+ maxOutputTokens: 1024,
175
173
  temperature: 0.2
176
174
  },
177
175
  responseValidation: {
178
176
  enabled: true,
179
- maxOutputTokens: 120,
177
+ maxOutputTokens: 1024,
180
178
  temperature: 0,
181
179
  maxRetries: 1,
182
180
  allowToolCalls: false,
@@ -223,10 +221,6 @@ const DEFAULT_AGENT_CONFIG: AgentRuntimeConfig['agent'] = {
223
221
  tools: ['WebFetch']
224
222
  }
225
223
  },
226
- streaming: {
227
- minIntervalMs: 800,
228
- minChars: 120
229
- },
230
224
  ipc: {
231
225
  requestTimeoutMs: 6000,
232
226
  requestPollMs: 150
@@ -275,7 +269,8 @@ function readJson(filePath: string): unknown {
275
269
  const raw = fs.readFileSync(filePath, 'utf-8');
276
270
  if (!raw.trim()) return null;
277
271
  return JSON.parse(raw);
278
- } catch {
272
+ } catch (err) {
273
+ console.error(`[agent-runner] Failed to load config ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
279
274
  return null;
280
275
  }
281
276
  }
@@ -286,6 +281,7 @@ export function loadAgentConfig(): AgentRuntimeConfig {
286
281
 
287
282
  let defaultModel = DEFAULT_DEFAULT_MODEL;
288
283
  let daemonPollMs = DEFAULT_DAEMON_POLL_MS;
284
+ let daemonHeartbeatIntervalMs = DEFAULT_DAEMON_HEARTBEAT_INTERVAL_MS;
289
285
  let agentOverrides: unknown = null;
290
286
 
291
287
  if (isPlainObject(raw)) {
@@ -295,8 +291,13 @@ export function loadAgentConfig(): AgentRuntimeConfig {
295
291
  defaultModel = host.defaultModel.trim();
296
292
  }
297
293
  const container = host.container;
298
- if (isPlainObject(container) && typeof container.daemonPollMs === 'number') {
299
- daemonPollMs = container.daemonPollMs;
294
+ if (isPlainObject(container)) {
295
+ if (typeof container.daemonPollMs === 'number') {
296
+ daemonPollMs = container.daemonPollMs;
297
+ }
298
+ if (typeof container.daemonHeartbeatIntervalMs === 'number') {
299
+ daemonHeartbeatIntervalMs = container.daemonHeartbeatIntervalMs;
300
+ }
300
301
  }
301
302
  }
302
303
  if (isPlainObject(raw.agent)) {
@@ -307,6 +308,7 @@ export function loadAgentConfig(): AgentRuntimeConfig {
307
308
  cachedConfig = {
308
309
  defaultModel,
309
310
  daemonPollMs,
311
+ daemonHeartbeatIntervalMs,
310
312
  agent: mergeDefaults(DEFAULT_AGENT_CONFIG, agentOverrides)
311
313
  };
312
314
  return cachedConfig;
@@ -45,12 +45,16 @@ export interface ContainerInput {
45
45
  disableResponseValidation?: boolean;
46
46
  responseValidationMaxRetries?: number;
47
47
  disableMemoryExtraction?: boolean;
48
- streaming?: {
49
- enabled?: boolean;
50
- draftId?: number;
51
- minIntervalMs?: number;
52
- minChars?: number;
53
- };
48
+ attachments?: Array<{
49
+ type: 'photo' | 'document' | 'voice' | 'video' | 'audio';
50
+ path: string;
51
+ file_name?: string;
52
+ mime_type?: string;
53
+ file_size?: number;
54
+ duration?: number;
55
+ width?: number;
56
+ height?: number;
57
+ }>;
54
58
  }
55
59
 
56
60
  export interface ContainerOutput {
@@ -1,13 +1,21 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { runAgentOnce } from './index.js';
3
+ import { Worker } from 'worker_threads';
4
+ import { fileURLToPath } from 'url';
4
5
  import { loadAgentConfig } from './agent-config.js';
5
- import type { ContainerInput } from './container-protocol.js';
6
+ import type { ContainerInput, ContainerOutput } from './container-protocol.js';
6
7
 
7
8
  const REQUESTS_DIR = '/workspace/ipc/agent_requests';
8
9
  const RESPONSES_DIR = '/workspace/ipc/agent_responses';
9
10
  const HEARTBEAT_FILE = '/workspace/ipc/heartbeat';
10
- const POLL_MS = loadAgentConfig().daemonPollMs;
11
+ const STATUS_FILE = '/workspace/ipc/daemon_status.json';
12
+
13
+ const config = loadAgentConfig();
14
+ const POLL_MS = config.daemonPollMs;
15
+ const HEARTBEAT_INTERVAL_MS = config.daemonHeartbeatIntervalMs;
16
+
17
+ let shuttingDown = false;
18
+ let heartbeatWorker: Worker | null = null;
11
19
 
12
20
  function log(message: string): void {
13
21
  console.error(`[agent-daemon] ${message}`);
@@ -18,66 +26,263 @@ function ensureDirs(): void {
18
26
  fs.mkdirSync(RESPONSES_DIR, { recursive: true });
19
27
  }
20
28
 
21
- function writeHeartbeat(): void {
29
+ // --- Worker thread management ---
30
+
31
+ function getWorkerPath(): string {
32
+ const thisFile = fileURLToPath(import.meta.url);
33
+ return path.join(path.dirname(thisFile), 'heartbeat-worker.js');
34
+ }
35
+
36
+ function getRequestWorkerPath(): string {
37
+ const thisFile = fileURLToPath(import.meta.url);
38
+ return path.join(path.dirname(thisFile), 'request-worker.js');
39
+ }
40
+
41
+ let workerRestarts = 0;
42
+ let workerRestartWindowStart = Date.now();
43
+ let workerRestarting = false;
44
+
45
+ function maybeRestartWorker(exitCode: number): void {
46
+ if (shuttingDown || exitCode === 0 || workerRestarting) return;
47
+ workerRestarting = true;
48
+ const now = Date.now();
49
+ if (now - workerRestartWindowStart > 60_000) {
50
+ workerRestarts = 0;
51
+ workerRestartWindowStart = now;
52
+ }
53
+ workerRestarts++;
54
+ if (workerRestarts > 5) {
55
+ log('Heartbeat worker crash loop detected, stopping restarts');
56
+ workerRestarting = false;
57
+ return;
58
+ }
59
+ const delay = Math.min(1000 * Math.pow(2, workerRestarts - 1), 10_000);
60
+ setTimeout(() => {
61
+ workerRestarting = false;
62
+ heartbeatWorker = spawnHeartbeatWorker();
63
+ }, delay);
64
+ }
65
+
66
+ function spawnHeartbeatWorker(): Worker {
67
+ const workerPath = getWorkerPath();
68
+ const worker = new Worker(workerPath, {
69
+ workerData: {
70
+ heartbeatPath: HEARTBEAT_FILE,
71
+ statusPath: STATUS_FILE,
72
+ intervalMs: HEARTBEAT_INTERVAL_MS,
73
+ pid: process.pid,
74
+ },
75
+ });
76
+
77
+ worker.on('error', (err) => {
78
+ log(`Heartbeat worker error: ${err.message}`);
79
+ maybeRestartWorker(1);
80
+ });
81
+
82
+ worker.on('exit', (code) => {
83
+ if (code !== 0) {
84
+ log(`Heartbeat worker exited with code ${code}`);
85
+ }
86
+ maybeRestartWorker(code ?? 1);
87
+ });
88
+
89
+ return worker;
90
+ }
91
+
92
+ function postWorkerMessage(msg: { type: string; requestId?: string }): void {
22
93
  try {
23
- fs.writeFileSync(HEARTBEAT_FILE, Date.now().toString());
94
+ heartbeatWorker?.postMessage(msg);
24
95
  } catch {
25
- // Ignore heartbeat write errors
96
+ // Worker may have died — will be restarted by exit handler
26
97
  }
27
98
  }
28
99
 
100
+ let currentRequestId: string | null = null;
101
+
102
+ async function runRequestWithCancellation(requestId: string, input: ContainerInput): Promise<{ output: ContainerOutput | null; canceled: boolean }> {
103
+ const cancelFile = path.join(REQUESTS_DIR, requestId + '.cancel');
104
+ const workerPath = getRequestWorkerPath();
105
+ const worker = new Worker(workerPath, { workerData: { input } });
106
+
107
+ return await new Promise((resolve, reject) => {
108
+ let settled = false;
109
+
110
+ const finish = (fn: () => void): void => {
111
+ if (settled) return;
112
+ settled = true;
113
+ clearInterval(cancelTimer);
114
+ fn();
115
+ };
116
+
117
+ const handleCancel = (): void => {
118
+ if (!fs.existsSync(cancelFile)) return;
119
+ finish(() => {
120
+ try { fs.unlinkSync(cancelFile); } catch { /* already removed */ }
121
+ void worker.terminate().catch(() => undefined);
122
+ resolve({ output: null, canceled: true });
123
+ });
124
+ };
125
+
126
+ const cancelTimer = setInterval(handleCancel, Math.max(100, Math.floor(POLL_MS / 2)));
127
+ handleCancel();
128
+
129
+ worker.on('message', (message: unknown) => {
130
+ finish(() => {
131
+ const payload = (message && typeof message === 'object') ? (message as Record<string, unknown>) : null;
132
+ if (payload?.ok === true && payload.output && typeof payload.output === 'object') {
133
+ resolve({ output: payload.output as ContainerOutput, canceled: false });
134
+ return;
135
+ }
136
+ const errMessage = typeof payload?.error === 'string' ? payload.error : 'Agent worker failed';
137
+ reject(new Error(errMessage));
138
+ });
139
+ });
140
+
141
+ worker.on('error', (err) => {
142
+ finish(() => reject(err));
143
+ });
144
+
145
+ worker.on('exit', (code) => {
146
+ finish(() => {
147
+ if (code === 0) {
148
+ reject(new Error('Agent worker exited without output'));
149
+ return;
150
+ }
151
+ reject(new Error(`Agent worker exited with code ${code}`));
152
+ });
153
+ });
154
+ });
155
+ }
156
+
157
+ // --- Request processing ---
158
+
159
+ function isContainerInput(value: unknown): value is ContainerInput {
160
+ if (!value || typeof value !== 'object') return false;
161
+ const record = value as Record<string, unknown>;
162
+ return typeof record.prompt === 'string'
163
+ && typeof record.groupFolder === 'string'
164
+ && typeof record.chatJid === 'string'
165
+ && typeof record.isMain === 'boolean';
166
+ }
167
+
29
168
  async function processRequests(): Promise<void> {
30
- const files = fs.readdirSync(REQUESTS_DIR).filter(file => file.endsWith('.json'));
169
+ const files = fs.readdirSync(REQUESTS_DIR).filter(file => file.endsWith('.json')).sort();
31
170
  for (const file of files) {
171
+ if (shuttingDown) break;
172
+
32
173
  const filePath = path.join(REQUESTS_DIR, file);
33
174
  let requestId = file.replace('.json', '');
175
+ const cancelFile = path.join(REQUESTS_DIR, requestId + '.cancel');
176
+ if (fs.existsSync(cancelFile)) {
177
+ try { fs.unlinkSync(filePath); } catch { /* already removed */ }
178
+ try { fs.unlinkSync(cancelFile); } catch { /* already removed */ }
179
+ continue;
180
+ }
34
181
  try {
35
- const raw = fs.readFileSync(filePath, 'utf-8');
182
+ let raw: string;
183
+ try {
184
+ raw = fs.readFileSync(filePath, 'utf-8');
185
+ } catch (readErr) {
186
+ if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') continue;
187
+ throw readErr;
188
+ }
36
189
  const payload = JSON.parse(raw) as { id?: string; input?: unknown };
37
190
  requestId = payload.id || requestId;
38
191
  const input = payload.input || payload;
39
192
  if (!isContainerInput(input)) {
40
193
  throw new Error('Invalid agent request payload');
41
194
  }
42
- const output = await runAgentOnce(input);
195
+
196
+ currentRequestId = requestId;
197
+ postWorkerMessage({ type: 'processing', requestId });
198
+
199
+ const { output, canceled } = await runRequestWithCancellation(requestId, input);
200
+ if (canceled) {
201
+ try { fs.unlinkSync(filePath); } catch { /* request file already removed */ }
202
+ continue;
203
+ }
204
+ if (!output) {
205
+ throw new Error('Agent worker returned no output');
206
+ }
207
+ if (fs.existsSync(cancelFile)) {
208
+ try { fs.unlinkSync(cancelFile); } catch { /* already removed */ }
209
+ try { fs.unlinkSync(filePath); } catch { /* request file already removed */ }
210
+ continue;
211
+ }
43
212
  const responsePath = path.join(RESPONSES_DIR, `${requestId}.json`);
44
- fs.writeFileSync(responsePath, JSON.stringify(output));
45
- fs.unlinkSync(filePath);
213
+ const tmpPath = responsePath + '.tmp';
214
+ fs.writeFileSync(tmpPath, JSON.stringify(output));
215
+ fs.renameSync(tmpPath, responsePath);
216
+ try { fs.unlinkSync(filePath); } catch { /* request file already removed */ }
46
217
  } catch (err) {
47
218
  log(`Failed processing request ${requestId}: ${err instanceof Error ? err.message : String(err)}`);
48
219
  const responsePath = path.join(RESPONSES_DIR, `${requestId}.json`);
49
- fs.writeFileSync(responsePath, JSON.stringify({
220
+ const tmpPath = responsePath + '.tmp';
221
+ fs.writeFileSync(tmpPath, JSON.stringify({
50
222
  status: 'error',
51
223
  result: null,
52
224
  error: err instanceof Error ? err.message : String(err)
53
225
  }));
54
- try {
55
- fs.unlinkSync(filePath);
56
- } catch {
57
- // ignore cleanup failure
58
- }
226
+ fs.renameSync(tmpPath, responsePath);
227
+ try { fs.unlinkSync(filePath); } catch { /* request file already removed */ }
228
+ } finally {
229
+ currentRequestId = null;
230
+ postWorkerMessage({ type: 'idle' });
59
231
  }
60
232
  }
61
233
  }
62
234
 
63
- function isContainerInput(value: unknown): value is ContainerInput {
64
- if (!value || typeof value !== 'object') return false;
65
- const record = value as Record<string, unknown>;
66
- return typeof record.prompt === 'string'
67
- && typeof record.groupFolder === 'string'
68
- && typeof record.chatJid === 'string'
69
- && typeof record.isMain === 'boolean';
235
+ // --- Graceful shutdown ---
236
+
237
+ function shutdown(signal: string): void {
238
+ if (shuttingDown) return;
239
+ shuttingDown = true;
240
+ log(`Received ${signal}, shutting down gracefully...`);
241
+ postWorkerMessage({ type: 'shutdown' });
242
+ const deadline = Date.now() + 30_000;
243
+ const check = () => {
244
+ if (!currentRequestId || Date.now() > deadline) {
245
+ if (currentRequestId) {
246
+ // Write aborted response for in-flight request
247
+ const responsePath = path.join(RESPONSES_DIR, `${currentRequestId}.json`);
248
+ try {
249
+ const tmpPath = responsePath + '.tmp';
250
+ fs.writeFileSync(tmpPath, JSON.stringify({ status: 'error', result: null, error: 'Daemon shutting down' }));
251
+ fs.renameSync(tmpPath, responsePath);
252
+ } catch { /* best-effort abort response */ }
253
+ }
254
+ log('Daemon stopped.');
255
+ process.exit(0);
256
+ }
257
+ setTimeout(check, 500);
258
+ };
259
+ check();
70
260
  }
71
261
 
262
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
263
+ process.on('SIGINT', () => shutdown('SIGINT'));
264
+
265
+ // --- Error handlers (keep daemon alive through individual failures) ---
266
+
267
+ process.on('unhandledRejection', (reason) => {
268
+ log(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
269
+ // Don't exit — daemon should survive individual request failures
270
+ });
271
+
272
+ process.on('uncaughtException', (err) => {
273
+ log(`Uncaught exception (fatal): ${err.message}`);
274
+ try { postWorkerMessage({ type: 'shutdown' }); } catch { /* worker may be dead */ }
275
+ process.exit(1);
276
+ });
277
+
278
+ // --- Main loop ---
279
+
72
280
  async function loop(): Promise<void> {
73
281
  ensureDirs();
74
- log('Daemon started');
75
- const heartbeatIntervalMs = Math.max(1000, Math.min(POLL_MS, 10_000));
76
- const heartbeatTimer = setInterval(writeHeartbeat, heartbeatIntervalMs);
77
- while (true) {
78
- // Write heartbeat at the start of each loop iteration
79
- writeHeartbeat();
282
+ heartbeatWorker = spawnHeartbeatWorker();
283
+ log('Daemon started (worker thread heartbeat active)');
80
284
 
285
+ while (!shuttingDown) {
81
286
  try {
82
287
  await processRequests();
83
288
  } catch (err) {
@@ -85,8 +290,8 @@ async function loop(): Promise<void> {
85
290
  }
86
291
  await new Promise(resolve => setTimeout(resolve, POLL_MS));
87
292
  }
88
- // Unreachable, but keep for clarity if loop ever exits
89
- clearInterval(heartbeatTimer);
293
+
294
+ log('Daemon loop exited.');
90
295
  }
91
296
 
92
297
  loop().catch(err => {