@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.
- package/README.md +8 -3
- package/config-examples/runtime.json +10 -8
- package/config-examples/tool-policy.json +17 -0
- package/container/Dockerfile +4 -0
- package/container/agent-runner/package-lock.json +2 -2
- package/container/agent-runner/package.json +1 -1
- package/container/agent-runner/src/agent-config.ts +17 -15
- package/container/agent-runner/src/container-protocol.ts +10 -6
- package/container/agent-runner/src/daemon.ts +237 -32
- package/container/agent-runner/src/heartbeat-worker.ts +94 -0
- package/container/agent-runner/src/id.ts +4 -0
- package/container/agent-runner/src/index.ts +356 -188
- package/container/agent-runner/src/ipc.ts +161 -12
- package/container/agent-runner/src/memory.ts +8 -2
- package/container/agent-runner/src/prompt-packs.ts +5 -209
- package/container/agent-runner/src/request-worker.ts +25 -0
- package/container/agent-runner/src/tools.ts +423 -32
- package/dist/agent-context.d.ts +1 -0
- package/dist/agent-context.d.ts.map +1 -1
- package/dist/agent-context.js +15 -7
- package/dist/agent-context.js.map +1 -1
- package/dist/agent-execution.d.ts +11 -6
- package/dist/agent-execution.d.ts.map +1 -1
- package/dist/agent-execution.js +6 -4
- package/dist/agent-execution.js.map +1 -1
- package/dist/background-jobs.d.ts +9 -0
- package/dist/background-jobs.d.ts.map +1 -1
- package/dist/background-jobs.js +70 -15
- package/dist/background-jobs.js.map +1 -1
- package/dist/behavior-config.d.ts +0 -1
- package/dist/behavior-config.d.ts.map +1 -1
- package/dist/behavior-config.js +0 -3
- package/dist/behavior-config.js.map +1 -1
- package/dist/cli.js +313 -45
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/container-protocol.d.ts +10 -6
- package/dist/container-protocol.d.ts.map +1 -1
- package/dist/container-runner.d.ts +28 -8
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +254 -54
- package/dist/container-runner.js.map +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/dashboard.js.map +1 -1
- package/dist/db.d.ts +31 -3
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +277 -39
- package/dist/db.js.map +1 -1
- package/dist/id.d.ts +2 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +4 -0
- package/dist/id.js.map +1 -0
- package/dist/index.js +1194 -381
- package/dist/index.js.map +1 -1
- package/dist/json-helpers.d.ts +1 -0
- package/dist/json-helpers.d.ts.map +1 -1
- package/dist/json-helpers.js +33 -1
- package/dist/json-helpers.js.map +1 -1
- package/dist/maintenance.d.ts +2 -0
- package/dist/maintenance.d.ts.map +1 -1
- package/dist/maintenance.js +196 -17
- package/dist/maintenance.js.map +1 -1
- package/dist/memory-embeddings.d.ts +1 -0
- package/dist/memory-embeddings.d.ts.map +1 -1
- package/dist/memory-embeddings.js +38 -4
- package/dist/memory-embeddings.js.map +1 -1
- package/dist/memory-recall.d.ts.map +1 -1
- package/dist/memory-recall.js +6 -1
- package/dist/memory-recall.js.map +1 -1
- package/dist/memory-store.d.ts +1 -0
- package/dist/memory-store.d.ts.map +1 -1
- package/dist/memory-store.js +72 -10
- package/dist/memory-store.js.map +1 -1
- package/dist/metrics.d.ts +1 -0
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +16 -2
- package/dist/metrics.js.map +1 -1
- package/dist/path-mapping.d.ts +4 -0
- package/dist/path-mapping.d.ts.map +1 -0
- package/dist/path-mapping.js +50 -0
- package/dist/path-mapping.js.map +1 -0
- package/dist/paths.d.ts +4 -2
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +4 -2
- package/dist/paths.js.map +1 -1
- package/dist/personalization.d.ts +1 -0
- package/dist/personalization.d.ts.map +1 -1
- package/dist/personalization.js +14 -0
- package/dist/personalization.js.map +1 -1
- package/dist/progress.d.ts +2 -2
- package/dist/progress.d.ts.map +1 -1
- package/dist/progress.js +18 -14
- package/dist/progress.js.map +1 -1
- package/dist/runtime-config.d.ts +18 -7
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +29 -18
- package/dist/runtime-config.js.map +1 -1
- package/dist/task-scheduler.d.ts +7 -1
- package/dist/task-scheduler.d.ts.map +1 -1
- package/dist/task-scheduler.js +182 -48
- package/dist/task-scheduler.js.map +1 -1
- package/dist/timezone.d.ts +4 -0
- package/dist/timezone.d.ts.map +1 -0
- package/dist/timezone.js +111 -0
- package/dist/timezone.js.map +1 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -1
- 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":
|
|
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":
|
|
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":
|
|
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":
|
|
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",
|
package/container/Dockerfile
CHANGED
|
@@ -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.
|
|
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.
|
|
9
|
+
"version": "1.6.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@openrouter/sdk": "^0.3.0",
|
|
12
12
|
"cron-parser": "^5.0.0",
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
172
|
+
maxOutputTokens: 1024,
|
|
175
173
|
temperature: 0.2
|
|
176
174
|
},
|
|
177
175
|
responseValidation: {
|
|
178
176
|
enabled: true,
|
|
179
|
-
maxOutputTokens:
|
|
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)
|
|
299
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
heartbeatWorker?.postMessage(msg);
|
|
24
95
|
} catch {
|
|
25
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
fs.
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
89
|
-
|
|
293
|
+
|
|
294
|
+
log('Daemon loop exited.');
|
|
90
295
|
}
|
|
91
296
|
|
|
92
297
|
loop().catch(err => {
|