@dotsetlabs/dotclaw 1.6.0 → 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 (87) hide show
  1. package/README.md +2 -0
  2. package/config-examples/runtime.json +1 -0
  3. package/config-examples/tool-policy.json +11 -0
  4. package/container/Dockerfile +4 -0
  5. package/container/agent-runner/package.json +1 -1
  6. package/container/agent-runner/src/agent-config.ts +11 -2
  7. package/container/agent-runner/src/container-protocol.ts +10 -0
  8. package/container/agent-runner/src/daemon.ts +237 -32
  9. package/container/agent-runner/src/heartbeat-worker.ts +94 -0
  10. package/container/agent-runner/src/index.ts +181 -10
  11. package/container/agent-runner/src/ipc.ts +165 -4
  12. package/container/agent-runner/src/memory.ts +8 -2
  13. package/container/agent-runner/src/request-worker.ts +25 -0
  14. package/container/agent-runner/src/tools.ts +417 -27
  15. package/dist/agent-context.d.ts +1 -0
  16. package/dist/agent-context.d.ts.map +1 -1
  17. package/dist/agent-context.js +15 -7
  18. package/dist/agent-context.js.map +1 -1
  19. package/dist/agent-execution.d.ts +11 -0
  20. package/dist/agent-execution.d.ts.map +1 -1
  21. package/dist/agent-execution.js +4 -2
  22. package/dist/agent-execution.js.map +1 -1
  23. package/dist/background-jobs.d.ts +9 -1
  24. package/dist/background-jobs.d.ts.map +1 -1
  25. package/dist/background-jobs.js +54 -14
  26. package/dist/background-jobs.js.map +1 -1
  27. package/dist/cli.js +19 -4
  28. package/dist/cli.js.map +1 -1
  29. package/dist/config.d.ts +5 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +5 -0
  32. package/dist/config.js.map +1 -1
  33. package/dist/container-protocol.d.ts +10 -0
  34. package/dist/container-protocol.d.ts.map +1 -1
  35. package/dist/container-runner.d.ts +23 -8
  36. package/dist/container-runner.d.ts.map +1 -1
  37. package/dist/container-runner.js +213 -55
  38. package/dist/container-runner.js.map +1 -1
  39. package/dist/db.d.ts +15 -5
  40. package/dist/db.d.ts.map +1 -1
  41. package/dist/db.js +156 -20
  42. package/dist/db.js.map +1 -1
  43. package/dist/index.js +1098 -165
  44. package/dist/index.js.map +1 -1
  45. package/dist/maintenance.d.ts +1 -0
  46. package/dist/maintenance.d.ts.map +1 -1
  47. package/dist/maintenance.js +188 -19
  48. package/dist/maintenance.js.map +1 -1
  49. package/dist/memory-embeddings.d.ts.map +1 -1
  50. package/dist/memory-embeddings.js +29 -4
  51. package/dist/memory-embeddings.js.map +1 -1
  52. package/dist/memory-recall.d.ts.map +1 -1
  53. package/dist/memory-recall.js +6 -1
  54. package/dist/memory-recall.js.map +1 -1
  55. package/dist/memory-store.d.ts +1 -0
  56. package/dist/memory-store.d.ts.map +1 -1
  57. package/dist/memory-store.js +70 -9
  58. package/dist/memory-store.js.map +1 -1
  59. package/dist/metrics.js +1 -1
  60. package/dist/metrics.js.map +1 -1
  61. package/dist/path-mapping.d.ts +4 -0
  62. package/dist/path-mapping.d.ts.map +1 -0
  63. package/dist/path-mapping.js +50 -0
  64. package/dist/path-mapping.js.map +1 -0
  65. package/dist/personalization.d.ts +1 -0
  66. package/dist/personalization.d.ts.map +1 -1
  67. package/dist/personalization.js +14 -0
  68. package/dist/personalization.js.map +1 -1
  69. package/dist/progress.d.ts +2 -2
  70. package/dist/progress.d.ts.map +1 -1
  71. package/dist/progress.js +18 -14
  72. package/dist/progress.js.map +1 -1
  73. package/dist/runtime-config.d.ts +14 -0
  74. package/dist/runtime-config.d.ts.map +1 -1
  75. package/dist/runtime-config.js +17 -3
  76. package/dist/runtime-config.js.map +1 -1
  77. package/dist/task-scheduler.d.ts +6 -1
  78. package/dist/task-scheduler.d.ts.map +1 -1
  79. package/dist/task-scheduler.js +172 -47
  80. package/dist/task-scheduler.js.map +1 -1
  81. package/dist/timezone.d.ts +4 -0
  82. package/dist/timezone.d.ts.map +1 -0
  83. package/dist/timezone.js +111 -0
  84. package/dist/timezone.js.map +1 -0
  85. package/dist/types.d.ts +17 -0
  86. package/dist/types.d.ts.map +1 -1
  87. package/package.json +1 -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
@@ -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": {
@@ -13,6 +13,17 @@
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",
17
28
  "mcp__dotclaw__run_task",
18
29
  "mcp__dotclaw__list_tasks",
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "dotclaw-agent-runner",
3
- "version": "1.6.0",
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: {
@@ -118,6 +119,7 @@ export type AgentRuntimeConfig = {
118
119
  const CONFIG_PATH = '/workspace/config/runtime.json';
119
120
  const DEFAULT_DEFAULT_MODEL = 'moonshotai/kimi-k2.5';
120
121
  const DEFAULT_DAEMON_POLL_MS = 200;
122
+ const DEFAULT_DAEMON_HEARTBEAT_INTERVAL_MS = 1_000;
121
123
 
122
124
  const DEFAULT_AGENT_CONFIG: AgentRuntimeConfig['agent'] = {
123
125
  assistantName: 'Rain',
@@ -279,6 +281,7 @@ export function loadAgentConfig(): AgentRuntimeConfig {
279
281
 
280
282
  let defaultModel = DEFAULT_DEFAULT_MODEL;
281
283
  let daemonPollMs = DEFAULT_DAEMON_POLL_MS;
284
+ let daemonHeartbeatIntervalMs = DEFAULT_DAEMON_HEARTBEAT_INTERVAL_MS;
282
285
  let agentOverrides: unknown = null;
283
286
 
284
287
  if (isPlainObject(raw)) {
@@ -288,8 +291,13 @@ export function loadAgentConfig(): AgentRuntimeConfig {
288
291
  defaultModel = host.defaultModel.trim();
289
292
  }
290
293
  const container = host.container;
291
- if (isPlainObject(container) && typeof container.daemonPollMs === 'number') {
292
- 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
+ }
293
301
  }
294
302
  }
295
303
  if (isPlainObject(raw.agent)) {
@@ -300,6 +308,7 @@ export function loadAgentConfig(): AgentRuntimeConfig {
300
308
  cachedConfig = {
301
309
  defaultModel,
302
310
  daemonPollMs,
311
+ daemonHeartbeatIntervalMs,
303
312
  agent: mergeDefaults(DEFAULT_AGENT_CONFIG, agentOverrides)
304
313
  };
305
314
  return cachedConfig;
@@ -45,6 +45,16 @@ export interface ContainerInput {
45
45
  disableResponseValidation?: boolean;
46
46
  responseValidationMaxRetries?: number;
47
47
  disableMemoryExtraction?: boolean;
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
+ }>;
48
58
  }
49
59
 
50
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 => {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Heartbeat Worker Thread
3
+ *
4
+ * Runs in a separate V8 isolate so heartbeat writes are never blocked
5
+ * by long-running agent tasks on the main thread. Writes two files:
6
+ *
7
+ * - /workspace/ipc/heartbeat — epoch ms (backward compatible)
8
+ * - /workspace/ipc/daemon_status.json — structured status with state info
9
+ */
10
+
11
+ import { parentPort, workerData } from 'worker_threads';
12
+ import fs from 'fs';
13
+
14
+ interface WorkerConfig {
15
+ heartbeatPath: string;
16
+ statusPath: string;
17
+ intervalMs: number;
18
+ pid: number;
19
+ }
20
+
21
+ type WorkerMessage =
22
+ | { type: 'processing'; requestId: string }
23
+ | { type: 'idle' }
24
+ | { type: 'shutdown' };
25
+
26
+ interface DaemonStatus {
27
+ state: 'idle' | 'processing';
28
+ ts: number;
29
+ request_id: string | null;
30
+ started_at: number | null;
31
+ pid: number;
32
+ }
33
+
34
+ const config = workerData as WorkerConfig;
35
+ let state: 'idle' | 'processing' = 'idle';
36
+ let requestId: string | null = null;
37
+ let startedAt: number | null = null;
38
+ function writeHeartbeat(): void {
39
+ const now = Date.now();
40
+ try {
41
+ const tmpHeartbeat = config.heartbeatPath + '.tmp';
42
+ fs.writeFileSync(tmpHeartbeat, now.toString());
43
+ fs.renameSync(tmpHeartbeat, config.heartbeatPath);
44
+ } catch {
45
+ // Ignore heartbeat write errors
46
+ }
47
+
48
+ const status: DaemonStatus = {
49
+ state,
50
+ ts: now,
51
+ request_id: requestId,
52
+ started_at: startedAt,
53
+ pid: config.pid,
54
+ };
55
+ try {
56
+ const tmpStatus = config.statusPath + '.tmp';
57
+ fs.writeFileSync(tmpStatus, JSON.stringify(status));
58
+ fs.renameSync(tmpStatus, config.statusPath);
59
+ } catch {
60
+ // Ignore status write errors
61
+ }
62
+ }
63
+
64
+ // Write immediately on start
65
+ writeHeartbeat();
66
+
67
+ const timer = setInterval(writeHeartbeat, config.intervalMs);
68
+
69
+ parentPort?.on('message', (msg: WorkerMessage) => {
70
+ switch (msg.type) {
71
+ case 'processing':
72
+ state = 'processing';
73
+ requestId = msg.requestId;
74
+ startedAt = Date.now();
75
+ writeHeartbeat(); // Immediate update
76
+ break;
77
+ case 'idle':
78
+ state = 'idle';
79
+ requestId = null;
80
+ startedAt = null;
81
+ writeHeartbeat(); // Immediate update
82
+ break;
83
+ case 'shutdown':
84
+ clearInterval(timer);
85
+ state = 'idle';
86
+ requestId = null;
87
+ startedAt = null;
88
+ writeHeartbeat(); // Final heartbeat
89
+ process.exit(0);
90
+ break;
91
+ }
92
+ });
93
+
94
+ // Worker stays alive via the setInterval timer