@dotsetlabs/dotclaw 1.1.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 (170) hide show
  1. package/.env.example +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +111 -0
  4. package/config-examples/groups/global/CLAUDE.md +21 -0
  5. package/config-examples/groups/main/CLAUDE.md +47 -0
  6. package/config-examples/mount-allowlist.json +25 -0
  7. package/config-examples/plugin-http.json +18 -0
  8. package/config-examples/runtime.json +30 -0
  9. package/config-examples/tool-budgets.json +24 -0
  10. package/config-examples/tool-policy.json +51 -0
  11. package/container/.dockerignore +6 -0
  12. package/container/Dockerfile +74 -0
  13. package/container/agent-runner/package-lock.json +92 -0
  14. package/container/agent-runner/package.json +20 -0
  15. package/container/agent-runner/src/agent-config.ts +295 -0
  16. package/container/agent-runner/src/container-protocol.ts +73 -0
  17. package/container/agent-runner/src/daemon.ts +91 -0
  18. package/container/agent-runner/src/index.ts +1428 -0
  19. package/container/agent-runner/src/ipc.ts +321 -0
  20. package/container/agent-runner/src/memory.ts +336 -0
  21. package/container/agent-runner/src/prompt-packs.ts +341 -0
  22. package/container/agent-runner/src/tools.ts +1720 -0
  23. package/container/agent-runner/tsconfig.json +19 -0
  24. package/container/build.sh +23 -0
  25. package/container/skills/agent-browser.md +159 -0
  26. package/dist/admin-commands.d.ts +7 -0
  27. package/dist/admin-commands.d.ts.map +1 -0
  28. package/dist/admin-commands.js +87 -0
  29. package/dist/admin-commands.js.map +1 -0
  30. package/dist/agent-context.d.ts +42 -0
  31. package/dist/agent-context.d.ts.map +1 -0
  32. package/dist/agent-context.js +92 -0
  33. package/dist/agent-context.js.map +1 -0
  34. package/dist/agent-execution.d.ts +68 -0
  35. package/dist/agent-execution.d.ts.map +1 -0
  36. package/dist/agent-execution.js +169 -0
  37. package/dist/agent-execution.js.map +1 -0
  38. package/dist/agent-semaphore.d.ts +2 -0
  39. package/dist/agent-semaphore.d.ts.map +1 -0
  40. package/dist/agent-semaphore.js +52 -0
  41. package/dist/agent-semaphore.js.map +1 -0
  42. package/dist/behavior-config.d.ts +14 -0
  43. package/dist/behavior-config.d.ts.map +1 -0
  44. package/dist/behavior-config.js +52 -0
  45. package/dist/behavior-config.js.map +1 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.d.ts.map +1 -0
  48. package/dist/cli.js +626 -0
  49. package/dist/cli.js.map +1 -0
  50. package/dist/config.d.ts +31 -0
  51. package/dist/config.d.ts.map +1 -0
  52. package/dist/config.js +38 -0
  53. package/dist/config.js.map +1 -0
  54. package/dist/container-protocol.d.ts +72 -0
  55. package/dist/container-protocol.d.ts.map +1 -0
  56. package/dist/container-protocol.js +3 -0
  57. package/dist/container-protocol.js.map +1 -0
  58. package/dist/container-runner.d.ts +59 -0
  59. package/dist/container-runner.d.ts.map +1 -0
  60. package/dist/container-runner.js +813 -0
  61. package/dist/container-runner.js.map +1 -0
  62. package/dist/cost.d.ts +9 -0
  63. package/dist/cost.d.ts.map +1 -0
  64. package/dist/cost.js +11 -0
  65. package/dist/cost.js.map +1 -0
  66. package/dist/dashboard.d.ts +58 -0
  67. package/dist/dashboard.d.ts.map +1 -0
  68. package/dist/dashboard.js +471 -0
  69. package/dist/dashboard.js.map +1 -0
  70. package/dist/db.d.ts +99 -0
  71. package/dist/db.d.ts.map +1 -0
  72. package/dist/db.js +423 -0
  73. package/dist/db.js.map +1 -0
  74. package/dist/error-messages.d.ts +17 -0
  75. package/dist/error-messages.d.ts.map +1 -0
  76. package/dist/error-messages.js +109 -0
  77. package/dist/error-messages.js.map +1 -0
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +2072 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/locks.d.ts +2 -0
  83. package/dist/locks.d.ts.map +1 -0
  84. package/dist/locks.js +26 -0
  85. package/dist/locks.js.map +1 -0
  86. package/dist/logger.d.ts +4 -0
  87. package/dist/logger.d.ts.map +1 -0
  88. package/dist/logger.js +15 -0
  89. package/dist/logger.js.map +1 -0
  90. package/dist/maintenance.d.ts +13 -0
  91. package/dist/maintenance.d.ts.map +1 -0
  92. package/dist/maintenance.js +151 -0
  93. package/dist/maintenance.js.map +1 -0
  94. package/dist/memory-embeddings.d.ts +13 -0
  95. package/dist/memory-embeddings.d.ts.map +1 -0
  96. package/dist/memory-embeddings.js +126 -0
  97. package/dist/memory-embeddings.js.map +1 -0
  98. package/dist/memory-recall.d.ts +8 -0
  99. package/dist/memory-recall.d.ts.map +1 -0
  100. package/dist/memory-recall.js +127 -0
  101. package/dist/memory-recall.js.map +1 -0
  102. package/dist/memory-store.d.ts +149 -0
  103. package/dist/memory-store.d.ts.map +1 -0
  104. package/dist/memory-store.js +787 -0
  105. package/dist/memory-store.js.map +1 -0
  106. package/dist/metrics.d.ts +12 -0
  107. package/dist/metrics.d.ts.map +1 -0
  108. package/dist/metrics.js +134 -0
  109. package/dist/metrics.js.map +1 -0
  110. package/dist/model-registry.d.ts +67 -0
  111. package/dist/model-registry.d.ts.map +1 -0
  112. package/dist/model-registry.js +230 -0
  113. package/dist/model-registry.js.map +1 -0
  114. package/dist/mount-security.d.ts +37 -0
  115. package/dist/mount-security.d.ts.map +1 -0
  116. package/dist/mount-security.js +284 -0
  117. package/dist/mount-security.js.map +1 -0
  118. package/dist/paths.d.ts +80 -0
  119. package/dist/paths.d.ts.map +1 -0
  120. package/dist/paths.js +149 -0
  121. package/dist/paths.js.map +1 -0
  122. package/dist/personalization.d.ts +6 -0
  123. package/dist/personalization.d.ts.map +1 -0
  124. package/dist/personalization.js +180 -0
  125. package/dist/personalization.js.map +1 -0
  126. package/dist/progress.d.ts +15 -0
  127. package/dist/progress.d.ts.map +1 -0
  128. package/dist/progress.js +92 -0
  129. package/dist/progress.js.map +1 -0
  130. package/dist/runtime-config.d.ts +227 -0
  131. package/dist/runtime-config.d.ts.map +1 -0
  132. package/dist/runtime-config.js +297 -0
  133. package/dist/runtime-config.js.map +1 -0
  134. package/dist/task-scheduler.d.ts +9 -0
  135. package/dist/task-scheduler.d.ts.map +1 -0
  136. package/dist/task-scheduler.js +195 -0
  137. package/dist/task-scheduler.js.map +1 -0
  138. package/dist/telegram-format.d.ts +3 -0
  139. package/dist/telegram-format.d.ts.map +1 -0
  140. package/dist/telegram-format.js +200 -0
  141. package/dist/telegram-format.js.map +1 -0
  142. package/dist/tool-budgets.d.ts +16 -0
  143. package/dist/tool-budgets.d.ts.map +1 -0
  144. package/dist/tool-budgets.js +83 -0
  145. package/dist/tool-budgets.js.map +1 -0
  146. package/dist/tool-policy.d.ts +18 -0
  147. package/dist/tool-policy.d.ts.map +1 -0
  148. package/dist/tool-policy.js +84 -0
  149. package/dist/tool-policy.js.map +1 -0
  150. package/dist/trace-writer.d.ts +39 -0
  151. package/dist/trace-writer.d.ts.map +1 -0
  152. package/dist/trace-writer.js +27 -0
  153. package/dist/trace-writer.js.map +1 -0
  154. package/dist/types.d.ts +81 -0
  155. package/dist/types.d.ts.map +1 -0
  156. package/dist/types.js +2 -0
  157. package/dist/types.js.map +1 -0
  158. package/dist/utils.d.ts +4 -0
  159. package/dist/utils.d.ts.map +1 -0
  160. package/dist/utils.js +30 -0
  161. package/dist/utils.js.map +1 -0
  162. package/launchd/com.dotclaw.plist +32 -0
  163. package/package.json +89 -0
  164. package/scripts/autotune.js +53 -0
  165. package/scripts/bootstrap.js +348 -0
  166. package/scripts/configure.js +200 -0
  167. package/scripts/doctor.js +164 -0
  168. package/scripts/init.js +209 -0
  169. package/scripts/install.sh +219 -0
  170. package/systemd/dotclaw.service +22 -0
@@ -0,0 +1,813 @@
1
+ /**
2
+ * Container Runner for DotClaw
3
+ * Spawns agent execution in Docker container and handles IPC
4
+ */
5
+ import { spawn, execSync, spawnSync } from 'child_process';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { CONTAINER_IMAGE, CONTAINER_TIMEOUT, CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_PIDS_LIMIT, CONTAINER_MEMORY, CONTAINER_CPUS, CONTAINER_READONLY_ROOT, CONTAINER_TMPFS_SIZE, CONTAINER_RUN_UID, CONTAINER_RUN_GID, CONTAINER_MODE, CONTAINER_DAEMON_POLL_MS, GROUPS_DIR, CONFIG_DIR, DATA_DIR, ENV_PATH, PROMPT_PACKS_DIR } from './config.js';
9
+ import { PACKAGE_ROOT } from './paths.js';
10
+ import { validateAdditionalMounts } from './mount-security.js';
11
+ import { loadRuntimeConfig } from './runtime-config.js';
12
+ import { OUTPUT_START_MARKER, OUTPUT_END_MARKER } from './container-protocol.js';
13
+ import { logger } from './logger.js';
14
+ const runtime = loadRuntimeConfig();
15
+ // Sentinel markers for robust output parsing (must match agent-runner)
16
+ const CONTAINER_ID_DIR = path.join(DATA_DIR, 'tmp');
17
+ function buildVolumeMounts(group, isMain) {
18
+ const mounts = [];
19
+ const envFile = ENV_PATH;
20
+ if (isMain) {
21
+ // Main gets the package root mounted (read-only for safety)
22
+ mounts.push({
23
+ hostPath: PACKAGE_ROOT,
24
+ containerPath: '/workspace/project',
25
+ readonly: true
26
+ });
27
+ // Mask .env inside the package root to avoid leaking secrets to the container
28
+ const packageEnvFile = path.join(PACKAGE_ROOT, '.env');
29
+ if (fs.existsSync(packageEnvFile)) {
30
+ const envMaskDir = path.join(DATA_DIR, 'env');
31
+ const envMaskFile = path.join(envMaskDir, '.env-mask');
32
+ fs.mkdirSync(envMaskDir, { recursive: true });
33
+ if (!fs.existsSync(envMaskFile)) {
34
+ fs.writeFileSync(envMaskFile, '');
35
+ }
36
+ mounts.push({
37
+ hostPath: envMaskFile,
38
+ containerPath: '/workspace/project/.env',
39
+ readonly: true
40
+ });
41
+ }
42
+ // Main also gets its group folder as the working directory
43
+ mounts.push({
44
+ hostPath: path.join(GROUPS_DIR, group.folder),
45
+ containerPath: '/workspace/group',
46
+ readonly: false
47
+ });
48
+ // Global memory/prompts directory (read-only)
49
+ const globalDir = path.join(GROUPS_DIR, 'global');
50
+ if (fs.existsSync(globalDir)) {
51
+ mounts.push({
52
+ hostPath: globalDir,
53
+ containerPath: '/workspace/global',
54
+ readonly: true
55
+ });
56
+ }
57
+ }
58
+ else {
59
+ // Other groups only get their own folder
60
+ mounts.push({
61
+ hostPath: path.join(GROUPS_DIR, group.folder),
62
+ containerPath: '/workspace/group',
63
+ readonly: false
64
+ });
65
+ // Global memory directory (read-only for non-main)
66
+ // Docker bind mounts work with both files and directories
67
+ const globalDir = path.join(GROUPS_DIR, 'global');
68
+ if (fs.existsSync(globalDir)) {
69
+ mounts.push({
70
+ hostPath: globalDir,
71
+ containerPath: '/workspace/global',
72
+ readonly: true
73
+ });
74
+ }
75
+ }
76
+ // Per-group OpenRouter sessions directory (isolated from other groups)
77
+ // Each group gets their own session store to prevent cross-group access
78
+ const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, 'openrouter');
79
+ fs.mkdirSync(groupSessionsDir, { recursive: true });
80
+ try {
81
+ fs.chmodSync(groupSessionsDir, 0o700);
82
+ }
83
+ catch {
84
+ logger.warn({ path: groupSessionsDir }, 'Could not chmod sessions directory');
85
+ }
86
+ mounts.push({
87
+ hostPath: groupSessionsDir,
88
+ containerPath: '/workspace/session',
89
+ readonly: false
90
+ });
91
+ // Per-group IPC namespace: each group gets its own IPC directory
92
+ // This prevents cross-group privilege escalation via IPC
93
+ const groupIpcDir = path.join(DATA_DIR, 'ipc', group.folder);
94
+ const messagesDir = path.join(groupIpcDir, 'messages');
95
+ const tasksDir = path.join(groupIpcDir, 'tasks');
96
+ const requestsDir = path.join(groupIpcDir, 'requests');
97
+ const responsesDir = path.join(groupIpcDir, 'responses');
98
+ const agentRequestsDir = path.join(groupIpcDir, 'agent_requests');
99
+ const agentResponsesDir = path.join(groupIpcDir, 'agent_responses');
100
+ fs.mkdirSync(messagesDir, { recursive: true });
101
+ fs.mkdirSync(tasksDir, { recursive: true });
102
+ fs.mkdirSync(requestsDir, { recursive: true });
103
+ fs.mkdirSync(responsesDir, { recursive: true });
104
+ fs.mkdirSync(agentRequestsDir, { recursive: true });
105
+ fs.mkdirSync(agentResponsesDir, { recursive: true });
106
+ // Ensure container user can write to IPC directories on Linux
107
+ // On macOS/Docker Desktop this is handled by file sharing, but on native Linux
108
+ // the container user needs explicit write permission to the mounted volume
109
+ // Use try/catch in case directories are owned by a different user (e.g., root)
110
+ try {
111
+ fs.chmodSync(groupIpcDir, 0o770);
112
+ fs.chmodSync(messagesDir, 0o770);
113
+ fs.chmodSync(tasksDir, 0o770);
114
+ fs.chmodSync(requestsDir, 0o770);
115
+ fs.chmodSync(responsesDir, 0o770);
116
+ fs.chmodSync(agentRequestsDir, 0o770);
117
+ fs.chmodSync(agentResponsesDir, 0o770);
118
+ }
119
+ catch {
120
+ // Permissions may already be correct, or user needs to fix ownership manually
121
+ logger.warn({ path: groupIpcDir, dataDir: DATA_DIR }, 'Could not chmod IPC directories - run: sudo chown -R $USER ~/.dotclaw/data/ipc');
122
+ }
123
+ mounts.push({
124
+ hostPath: groupIpcDir,
125
+ containerPath: '/workspace/ipc',
126
+ readonly: false
127
+ });
128
+ // Shared prompt packs directory (autotune output)
129
+ if (PROMPT_PACKS_DIR && fs.existsSync(PROMPT_PACKS_DIR)) {
130
+ mounts.push({
131
+ hostPath: PROMPT_PACKS_DIR,
132
+ containerPath: '/workspace/prompts',
133
+ readonly: true
134
+ });
135
+ }
136
+ // Config directory (read-only) - contains runtime.json and other config files
137
+ if (fs.existsSync(CONFIG_DIR)) {
138
+ mounts.push({
139
+ hostPath: CONFIG_DIR,
140
+ containerPath: '/workspace/config',
141
+ readonly: true
142
+ });
143
+ }
144
+ // Data directory (read-only) - contains databases, sessions, etc.
145
+ // Note: do NOT mount the full data directory into containers.
146
+ // This prevents cross-group leakage of sessions, IPC, and databases.
147
+ // Environment file directory (keeps credentials out of process listings)
148
+ // Inject only secrets from .env
149
+ const envDir = path.join(DATA_DIR, 'env');
150
+ fs.mkdirSync(envDir, { recursive: true });
151
+ try {
152
+ fs.chmodSync(envDir, 0o700);
153
+ }
154
+ catch {
155
+ logger.warn({ path: envDir }, 'Could not chmod env directory');
156
+ }
157
+ const envVars = new Map();
158
+ const setEnvVar = (key, value, source) => {
159
+ if (!/^[A-Z0-9_]+$/.test(key)) {
160
+ logger.warn({ key, source }, 'Skipping invalid env var name for container');
161
+ return;
162
+ }
163
+ if (value.includes('\n')) {
164
+ logger.warn({ key, source }, 'Skipping env var with newline for container');
165
+ return;
166
+ }
167
+ envVars.set(key, value);
168
+ };
169
+ if (fs.existsSync(envFile)) {
170
+ const envContent = fs.readFileSync(envFile, 'utf-8');
171
+ const secretVars = new Set([
172
+ 'OPENROUTER_API_KEY',
173
+ 'BRAVE_SEARCH_API_KEY'
174
+ ]);
175
+ for (const line of envContent.split('\n')) {
176
+ const trimmed = line.trim();
177
+ if (!trimmed || trimmed.startsWith('#'))
178
+ continue;
179
+ const idx = trimmed.indexOf('=');
180
+ if (idx === -1)
181
+ continue;
182
+ const key = trimmed.slice(0, idx).trim();
183
+ if (!secretVars.has(key))
184
+ continue;
185
+ const value = trimmed.slice(idx + 1);
186
+ setEnvVar(key, value, 'dotenv');
187
+ }
188
+ }
189
+ if (group.containerConfig?.env) {
190
+ for (const [key, value] of Object.entries(group.containerConfig.env)) {
191
+ if (typeof value !== 'string')
192
+ continue;
193
+ setEnvVar(key, value, 'containerConfig');
194
+ }
195
+ }
196
+ if (envVars.size > 0) {
197
+ const mergedLines = Array.from(envVars.entries()).map(([key, value]) => `${key}=${value}`);
198
+ const envOutPath = path.join(envDir, 'env');
199
+ fs.writeFileSync(envOutPath, mergedLines.join('\n') + '\n');
200
+ try {
201
+ fs.chmodSync(envOutPath, 0o600);
202
+ }
203
+ catch {
204
+ logger.warn({ path: envOutPath }, 'Could not chmod env file');
205
+ }
206
+ mounts.push({
207
+ hostPath: envDir,
208
+ containerPath: '/workspace/env-dir',
209
+ readonly: true
210
+ });
211
+ }
212
+ // Additional mounts validated against external allowlist (tamper-proof from containers)
213
+ if (group.containerConfig?.additionalMounts) {
214
+ const validatedMounts = validateAdditionalMounts(group.containerConfig.additionalMounts, group.name, isMain);
215
+ mounts.push(...validatedMounts);
216
+ }
217
+ return mounts;
218
+ }
219
+ function buildContainerArgs(mounts, cidFile) {
220
+ const args = ['run', '-i', '--rm'];
221
+ // Security hardening
222
+ args.push('--cap-drop=ALL');
223
+ args.push('--security-opt=no-new-privileges');
224
+ args.push(`--pids-limit=${CONTAINER_PIDS_LIMIT}`);
225
+ if (cidFile) {
226
+ args.push('--cidfile', cidFile);
227
+ }
228
+ const runUid = CONTAINER_RUN_UID ? CONTAINER_RUN_UID.trim() : '';
229
+ const runGid = CONTAINER_RUN_GID ? CONTAINER_RUN_GID.trim() : '';
230
+ if (runUid) {
231
+ args.push('--user', runGid ? `${runUid}:${runGid}` : runUid);
232
+ }
233
+ args.push('--env', 'HOME=/tmp');
234
+ if (CONTAINER_MEMORY) {
235
+ args.push(`--memory=${CONTAINER_MEMORY}`);
236
+ }
237
+ if (CONTAINER_CPUS) {
238
+ args.push(`--cpus=${CONTAINER_CPUS}`);
239
+ }
240
+ if (CONTAINER_READONLY_ROOT) {
241
+ args.push('--read-only');
242
+ const tmpfsOptions = ['rw', 'noexec', 'nosuid', `size=${CONTAINER_TMPFS_SIZE}`];
243
+ if (runUid)
244
+ tmpfsOptions.push(`uid=${runUid}`);
245
+ if (runGid)
246
+ tmpfsOptions.push(`gid=${runGid}`);
247
+ args.push('--tmpfs', `/tmp:${tmpfsOptions.join(',')}`);
248
+ args.push('--tmpfs', `/home/node:${tmpfsOptions.join(',')}`);
249
+ }
250
+ // Docker: -v with :ro suffix for readonly
251
+ for (const mount of mounts) {
252
+ if (mount.readonly) {
253
+ args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`);
254
+ }
255
+ else {
256
+ args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
257
+ }
258
+ }
259
+ args.push(CONTAINER_IMAGE);
260
+ return args;
261
+ }
262
+ function buildDaemonArgs(mounts, containerName, groupFolder) {
263
+ const args = ['run', '-d', '--rm', '--name', containerName, '--label', `dotclaw.group=${groupFolder}`];
264
+ // Security hardening
265
+ args.push('--cap-drop=ALL');
266
+ args.push('--security-opt=no-new-privileges');
267
+ args.push(`--pids-limit=${CONTAINER_PIDS_LIMIT}`);
268
+ const runUid = CONTAINER_RUN_UID ? CONTAINER_RUN_UID.trim() : '';
269
+ const runGid = CONTAINER_RUN_GID ? CONTAINER_RUN_GID.trim() : '';
270
+ if (runUid) {
271
+ args.push('--user', runGid ? `${runUid}:${runGid}` : runUid);
272
+ }
273
+ args.push('--env', 'HOME=/tmp');
274
+ args.push('--env', 'DOTCLAW_DAEMON=1');
275
+ if (CONTAINER_MEMORY) {
276
+ args.push(`--memory=${CONTAINER_MEMORY}`);
277
+ }
278
+ if (CONTAINER_CPUS) {
279
+ args.push(`--cpus=${CONTAINER_CPUS}`);
280
+ }
281
+ if (CONTAINER_READONLY_ROOT) {
282
+ args.push('--read-only');
283
+ const tmpfsOptions = ['rw', 'noexec', 'nosuid', `size=${CONTAINER_TMPFS_SIZE}`];
284
+ if (runUid)
285
+ tmpfsOptions.push(`uid=${runUid}`);
286
+ if (runGid)
287
+ tmpfsOptions.push(`gid=${runGid}`);
288
+ args.push('--tmpfs', `/tmp:${tmpfsOptions.join(',')}`);
289
+ args.push('--tmpfs', `/home/node:${tmpfsOptions.join(',')}`);
290
+ }
291
+ for (const mount of mounts) {
292
+ if (mount.readonly) {
293
+ args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`);
294
+ }
295
+ else {
296
+ args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
297
+ }
298
+ }
299
+ args.push(CONTAINER_IMAGE);
300
+ return args;
301
+ }
302
+ function getDaemonContainerName(groupFolder) {
303
+ return `dotclaw-agent-${groupFolder}`;
304
+ }
305
+ function isContainerRunning(name) {
306
+ try {
307
+ const output = execSync(`docker ps --filter "name=${name}" --format "{{.ID}}"`, { stdio: 'pipe' })
308
+ .toString()
309
+ .trim();
310
+ return output.length > 0;
311
+ }
312
+ catch {
313
+ return false;
314
+ }
315
+ }
316
+ const DAEMON_HEALTH_CHECK_INTERVAL_MS = 60_000; // Check every 60 seconds
317
+ const DAEMON_HEARTBEAT_MAX_AGE_MS = 30_000; // Consider unhealthy if heartbeat older than 30s
318
+ /**
319
+ * Check if a daemon container is healthy by reading its heartbeat file
320
+ */
321
+ export function checkDaemonHealth(groupFolder) {
322
+ const heartbeatPath = path.join(DATA_DIR, 'ipc', groupFolder, 'heartbeat');
323
+ try {
324
+ if (!fs.existsSync(heartbeatPath)) {
325
+ return { healthy: false };
326
+ }
327
+ const content = fs.readFileSync(heartbeatPath, 'utf-8').trim();
328
+ const lastHeartbeat = parseInt(content, 10);
329
+ if (!Number.isFinite(lastHeartbeat)) {
330
+ return { healthy: false };
331
+ }
332
+ const ageMs = Date.now() - lastHeartbeat;
333
+ return {
334
+ healthy: ageMs < DAEMON_HEARTBEAT_MAX_AGE_MS,
335
+ lastHeartbeat,
336
+ ageMs
337
+ };
338
+ }
339
+ catch {
340
+ return { healthy: false };
341
+ }
342
+ }
343
+ /**
344
+ * Restart a daemon container if unhealthy
345
+ */
346
+ export function restartDaemonContainer(group, isMain) {
347
+ const containerName = getDaemonContainerName(group.folder);
348
+ // Stop existing container
349
+ try {
350
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
351
+ }
352
+ catch {
353
+ // Ignore if container doesn't exist
354
+ }
355
+ // Start new container
356
+ const mounts = buildVolumeMounts(group, isMain);
357
+ ensureDaemonContainer(mounts, group.folder);
358
+ logger.info({ groupFolder: group.folder }, 'Daemon container restarted');
359
+ }
360
+ // Track daemon health check state
361
+ let healthCheckInterval = null;
362
+ const unhealthyDaemons = new Map(); // Track consecutive unhealthy checks
363
+ /**
364
+ * Perform health check on all daemon containers and restart if needed
365
+ */
366
+ export function performDaemonHealthChecks(getRegisteredGroups, mainGroupFolder) {
367
+ if (CONTAINER_MODE !== 'daemon')
368
+ return;
369
+ const groups = getRegisteredGroups();
370
+ for (const [, group] of Object.entries(groups)) {
371
+ const containerName = getDaemonContainerName(group.folder);
372
+ // Skip if container isn't running (may be intentionally stopped)
373
+ if (!isContainerRunning(containerName)) {
374
+ unhealthyDaemons.delete(group.folder);
375
+ continue;
376
+ }
377
+ const health = checkDaemonHealth(group.folder);
378
+ if (health.healthy) {
379
+ unhealthyDaemons.delete(group.folder);
380
+ }
381
+ else {
382
+ const consecutiveFailures = (unhealthyDaemons.get(group.folder) || 0) + 1;
383
+ unhealthyDaemons.set(group.folder, consecutiveFailures);
384
+ logger.warn({
385
+ groupFolder: group.folder,
386
+ consecutiveFailures,
387
+ ageMs: health.ageMs
388
+ }, 'Daemon container unhealthy');
389
+ // Restart after 2 consecutive failures
390
+ if (consecutiveFailures >= 2) {
391
+ logger.info({ groupFolder: group.folder }, 'Restarting unhealthy daemon');
392
+ restartDaemonContainer(group, group.folder === mainGroupFolder);
393
+ unhealthyDaemons.delete(group.folder);
394
+ }
395
+ }
396
+ }
397
+ }
398
+ /**
399
+ * Start the daemon health check loop
400
+ */
401
+ export function startDaemonHealthCheckLoop(getRegisteredGroups, mainGroupFolder) {
402
+ if (CONTAINER_MODE !== 'daemon')
403
+ return;
404
+ if (healthCheckInterval)
405
+ return;
406
+ healthCheckInterval = setInterval(() => {
407
+ performDaemonHealthChecks(getRegisteredGroups, mainGroupFolder);
408
+ }, DAEMON_HEALTH_CHECK_INTERVAL_MS);
409
+ logger.info('Daemon health check loop started');
410
+ }
411
+ /**
412
+ * Stop the daemon health check loop
413
+ */
414
+ export function stopDaemonHealthCheckLoop() {
415
+ if (healthCheckInterval) {
416
+ clearInterval(healthCheckInterval);
417
+ healthCheckInterval = null;
418
+ }
419
+ }
420
+ function ensureDaemonContainer(mounts, groupFolder) {
421
+ const containerName = getDaemonContainerName(groupFolder);
422
+ if (isContainerRunning(containerName))
423
+ return;
424
+ try {
425
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
426
+ }
427
+ catch {
428
+ // ignore if container doesn't exist
429
+ }
430
+ const args = buildDaemonArgs(mounts, containerName, groupFolder);
431
+ const result = spawnSync('docker', args, { stdio: 'ignore' });
432
+ if (result.status !== 0) {
433
+ logger.error({ groupFolder, status: result.status }, 'Failed to start daemon container');
434
+ throw new Error(`Failed to start daemon container for ${groupFolder}`);
435
+ }
436
+ }
437
+ export function warmGroupContainer(group, isMain) {
438
+ if (CONTAINER_MODE !== 'daemon')
439
+ return;
440
+ const mounts = buildVolumeMounts(group, isMain);
441
+ ensureDaemonContainer(mounts, group.folder);
442
+ }
443
+ function writeAgentRequest(groupFolder, payload) {
444
+ const id = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
445
+ const requestsDir = path.join(DATA_DIR, 'ipc', groupFolder, 'agent_requests');
446
+ const responsesDir = path.join(DATA_DIR, 'ipc', groupFolder, 'agent_responses');
447
+ fs.mkdirSync(requestsDir, { recursive: true });
448
+ fs.mkdirSync(responsesDir, { recursive: true });
449
+ const requestPath = path.join(requestsDir, `${id}.json`);
450
+ const responsePath = path.join(responsesDir, `${id}.json`);
451
+ const tempPath = `${requestPath}.tmp`;
452
+ fs.writeFileSync(tempPath, JSON.stringify({ id, input: payload }, null, 2));
453
+ fs.renameSync(tempPath, requestPath);
454
+ return { id, requestPath, responsePath };
455
+ }
456
+ async function waitForAgentResponse(responsePath, timeoutMs, abortSignal) {
457
+ const start = Date.now();
458
+ while (Date.now() - start < timeoutMs) {
459
+ if (abortSignal?.aborted) {
460
+ throw new Error('Agent run preempted');
461
+ }
462
+ if (fs.existsSync(responsePath)) {
463
+ const raw = fs.readFileSync(responsePath, 'utf-8');
464
+ fs.unlinkSync(responsePath);
465
+ try {
466
+ return JSON.parse(raw);
467
+ }
468
+ catch (err) {
469
+ throw new Error(`Failed to parse daemon response: ${err instanceof Error ? err.message : String(err)}`);
470
+ }
471
+ }
472
+ await new Promise(resolve => setTimeout(resolve, CONTAINER_DAEMON_POLL_MS));
473
+ }
474
+ throw new Error(`Daemon response timeout after ${timeoutMs}ms`);
475
+ }
476
+ function readContainerId(cidFile) {
477
+ try {
478
+ const id = fs.readFileSync(cidFile, 'utf-8').trim();
479
+ return id ? id : null;
480
+ }
481
+ catch {
482
+ return null;
483
+ }
484
+ }
485
+ function removeContainerById(containerId, reason) {
486
+ if (!containerId)
487
+ return;
488
+ logger.warn({ containerId, reason }, 'Removing container');
489
+ spawn('docker', ['rm', '-f', containerId], { stdio: 'ignore' });
490
+ }
491
+ export async function runContainerAgent(group, input, options) {
492
+ if (CONTAINER_MODE === 'daemon') {
493
+ return runContainerAgentDaemon(group, input, options);
494
+ }
495
+ const startTime = Date.now();
496
+ const groupDir = path.join(GROUPS_DIR, group.folder);
497
+ fs.mkdirSync(groupDir, { recursive: true });
498
+ const mounts = buildVolumeMounts(group, input.isMain);
499
+ fs.mkdirSync(CONTAINER_ID_DIR, { recursive: true });
500
+ const cidFile = path.join(CONTAINER_ID_DIR, `container-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cid`);
501
+ try {
502
+ fs.rmSync(cidFile, { force: true });
503
+ }
504
+ catch {
505
+ // ignore cleanup failure
506
+ }
507
+ const containerArgs = buildContainerArgs(mounts, cidFile);
508
+ logger.debug({
509
+ group: group.name,
510
+ mounts: mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`),
511
+ containerArgs: containerArgs.join(' ')
512
+ }, 'Container mount configuration');
513
+ logger.info({
514
+ group: group.name,
515
+ mountCount: mounts.length,
516
+ isMain: input.isMain
517
+ }, 'Spawning container agent');
518
+ const logsDir = path.join(GROUPS_DIR, group.folder, 'logs');
519
+ fs.mkdirSync(logsDir, { recursive: true });
520
+ return new Promise((resolve) => {
521
+ const container = spawn('docker', containerArgs, {
522
+ stdio: ['pipe', 'pipe', 'pipe']
523
+ });
524
+ let stdout = '';
525
+ let stderr = '';
526
+ let stdoutTruncated = false;
527
+ let stderrTruncated = false;
528
+ let resolved = false;
529
+ container.stdin.write(JSON.stringify(input));
530
+ container.stdin.end();
531
+ container.stdout.on('data', (data) => {
532
+ if (stdoutTruncated)
533
+ return;
534
+ const chunk = data.toString();
535
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
536
+ if (chunk.length > remaining) {
537
+ stdout += chunk.slice(0, remaining);
538
+ stdoutTruncated = true;
539
+ logger.warn({ group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit');
540
+ }
541
+ else {
542
+ stdout += chunk;
543
+ }
544
+ });
545
+ container.stderr.on('data', (data) => {
546
+ const chunk = data.toString();
547
+ const lines = chunk.trim().split('\n');
548
+ for (const line of lines) {
549
+ if (line)
550
+ logger.debug({ container: group.folder }, line);
551
+ }
552
+ if (stderrTruncated)
553
+ return;
554
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
555
+ if (chunk.length > remaining) {
556
+ stderr += chunk.slice(0, remaining);
557
+ stderrTruncated = true;
558
+ logger.warn({ group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit');
559
+ }
560
+ else {
561
+ stderr += chunk;
562
+ }
563
+ });
564
+ const cleanupCid = () => {
565
+ try {
566
+ fs.rmSync(cidFile, { force: true });
567
+ }
568
+ catch {
569
+ // ignore cleanup failure
570
+ }
571
+ };
572
+ const stopContainer = (reason) => {
573
+ const containerId = readContainerId(cidFile);
574
+ if (containerId) {
575
+ removeContainerById(containerId, reason);
576
+ }
577
+ };
578
+ const timeoutMs = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
579
+ const timeout = setTimeout(() => {
580
+ logger.error({ group: group.name }, 'Container timeout, killing');
581
+ stopContainer('timeout');
582
+ container.kill('SIGKILL');
583
+ if (resolved)
584
+ return;
585
+ resolved = true;
586
+ resolve({
587
+ status: 'error',
588
+ result: null,
589
+ error: `Container timed out after ${timeoutMs}ms`
590
+ });
591
+ }, timeoutMs);
592
+ const abortSignal = options?.abortSignal;
593
+ const abortHandler = () => {
594
+ if (resolved)
595
+ return;
596
+ resolved = true;
597
+ logger.warn({ group: group.name }, 'Container run preempted');
598
+ stopContainer('preempted');
599
+ container.kill('SIGKILL');
600
+ clearTimeout(timeout);
601
+ cleanupCid();
602
+ resolve({
603
+ status: 'error',
604
+ result: null,
605
+ error: 'Container run preempted'
606
+ });
607
+ };
608
+ if (abortSignal) {
609
+ if (abortSignal.aborted) {
610
+ abortHandler();
611
+ }
612
+ else {
613
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
614
+ }
615
+ }
616
+ container.on('close', (code) => {
617
+ if (resolved)
618
+ return;
619
+ resolved = true;
620
+ clearTimeout(timeout);
621
+ if (abortSignal) {
622
+ abortSignal.removeEventListener('abort', abortHandler);
623
+ }
624
+ cleanupCid();
625
+ const duration = Date.now() - startTime;
626
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
627
+ const logFile = path.join(logsDir, `container-${timestamp}.log`);
628
+ const isVerbose = runtime.host.logLevel === 'debug' || runtime.host.logLevel === 'trace';
629
+ const logLines = [
630
+ `=== Container Run Log ===`,
631
+ `Timestamp: ${new Date().toISOString()}`,
632
+ `Group: ${group.name}`,
633
+ `IsMain: ${input.isMain}`,
634
+ `Duration: ${duration}ms`,
635
+ `Exit Code: ${code}`,
636
+ `Stdout Truncated: ${stdoutTruncated}`,
637
+ `Stderr Truncated: ${stderrTruncated}`,
638
+ ``
639
+ ];
640
+ if (isVerbose) {
641
+ logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``, `=== Container Args ===`, containerArgs.join(' '), ``, `=== Mounts ===`, mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), ``, `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, stderr, ``, `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, stdout);
642
+ }
643
+ else {
644
+ logLines.push(`=== Input Summary ===`, `Prompt length: ${input.prompt.length} chars`, `Session ID: ${input.sessionId || 'new'}`, ``, `=== Mounts ===`, mounts.map(m => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), ``);
645
+ if (code !== 0) {
646
+ logLines.push(`=== Stderr (last 500 chars) ===`, stderr.slice(-500), ``);
647
+ }
648
+ }
649
+ fs.writeFileSync(logFile, logLines.join('\n'));
650
+ logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
651
+ if (code !== 0) {
652
+ logger.error({
653
+ group: group.name,
654
+ code,
655
+ duration,
656
+ stderr: stderr.slice(-500),
657
+ logFile
658
+ }, 'Container exited with error');
659
+ resolve({
660
+ status: 'error',
661
+ result: null,
662
+ error: `Container exited with code ${code}: ${stderr.slice(-200)}`
663
+ });
664
+ return;
665
+ }
666
+ try {
667
+ // Extract JSON between sentinel markers for robust parsing
668
+ const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
669
+ const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
670
+ let jsonLine;
671
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
672
+ jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim();
673
+ }
674
+ else {
675
+ // Fallback: last non-empty line (backwards compatibility)
676
+ const lines = stdout.trim().split('\n');
677
+ jsonLine = lines[lines.length - 1];
678
+ }
679
+ const output = JSON.parse(jsonLine);
680
+ logger.info({
681
+ group: group.name,
682
+ duration,
683
+ status: output.status,
684
+ hasResult: !!output.result
685
+ }, 'Container completed');
686
+ resolve(output);
687
+ }
688
+ catch (err) {
689
+ logger.error({
690
+ group: group.name,
691
+ stdout: stdout.slice(-500),
692
+ error: err
693
+ }, 'Failed to parse container output');
694
+ resolve({
695
+ status: 'error',
696
+ result: null,
697
+ error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`
698
+ });
699
+ }
700
+ });
701
+ container.on('error', (err) => {
702
+ if (resolved)
703
+ return;
704
+ resolved = true;
705
+ clearTimeout(timeout);
706
+ if (abortSignal) {
707
+ abortSignal.removeEventListener('abort', abortHandler);
708
+ }
709
+ cleanupCid();
710
+ logger.error({ group: group.name, error: err }, 'Container spawn error');
711
+ resolve({
712
+ status: 'error',
713
+ result: null,
714
+ error: `Container spawn error: ${err.message}`
715
+ });
716
+ });
717
+ });
718
+ }
719
+ async function runContainerAgentDaemon(group, input, options) {
720
+ const startTime = Date.now();
721
+ const groupDir = path.join(GROUPS_DIR, group.folder);
722
+ fs.mkdirSync(groupDir, { recursive: true });
723
+ const mounts = buildVolumeMounts(group, input.isMain);
724
+ ensureDaemonContainer(mounts, group.folder);
725
+ const { responsePath, requestPath } = writeAgentRequest(group.folder, input);
726
+ const timeoutMs = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
727
+ const abortSignal = options?.abortSignal;
728
+ const containerName = getDaemonContainerName(group.folder);
729
+ const abortHandler = () => {
730
+ logger.warn({ group: group.name }, 'Daemon run preempted');
731
+ try {
732
+ if (fs.existsSync(requestPath))
733
+ fs.unlinkSync(requestPath);
734
+ }
735
+ catch {
736
+ // ignore cleanup failure
737
+ }
738
+ spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
739
+ };
740
+ if (abortSignal) {
741
+ if (abortSignal.aborted) {
742
+ abortHandler();
743
+ return {
744
+ status: 'error',
745
+ result: null,
746
+ error: 'Daemon run preempted'
747
+ };
748
+ }
749
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
750
+ }
751
+ try {
752
+ const output = await waitForAgentResponse(responsePath, timeoutMs, abortSignal);
753
+ return {
754
+ ...output,
755
+ latency_ms: output.latency_ms ?? (Date.now() - startTime)
756
+ };
757
+ }
758
+ catch (err) {
759
+ const errorMessage = err instanceof Error ? err.message : String(err);
760
+ logger.error({ group: group.name, error: errorMessage }, 'Daemon agent error');
761
+ try {
762
+ if (fs.existsSync(requestPath))
763
+ fs.unlinkSync(requestPath);
764
+ }
765
+ catch {
766
+ // ignore cleanup failure
767
+ }
768
+ try {
769
+ spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
770
+ }
771
+ catch {
772
+ // ignore cleanup failure
773
+ }
774
+ return {
775
+ status: 'error',
776
+ result: null,
777
+ error: errorMessage
778
+ };
779
+ }
780
+ finally {
781
+ if (abortSignal) {
782
+ abortSignal.removeEventListener('abort', abortHandler);
783
+ }
784
+ }
785
+ }
786
+ export function writeTasksSnapshot(groupFolder, isMain, tasks) {
787
+ // Write filtered tasks to the group's IPC directory
788
+ const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
789
+ fs.mkdirSync(groupIpcDir, { recursive: true });
790
+ // Main sees all tasks, others only see their own
791
+ const filteredTasks = isMain
792
+ ? tasks
793
+ : tasks.filter(t => t.groupFolder === groupFolder);
794
+ const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
795
+ fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
796
+ }
797
+ /**
798
+ * Write available groups snapshot for the container to read.
799
+ * Only main group can see all available groups (for activation).
800
+ * Non-main groups only see their own registration status.
801
+ */
802
+ export function writeGroupsSnapshot(groupFolder, isMain, groups) {
803
+ const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
804
+ fs.mkdirSync(groupIpcDir, { recursive: true });
805
+ // Main sees all groups; others see nothing (they can't activate groups)
806
+ const visibleGroups = isMain ? groups : [];
807
+ const groupsFile = path.join(groupIpcDir, 'available_groups.json');
808
+ fs.writeFileSync(groupsFile, JSON.stringify({
809
+ groups: visibleGroups,
810
+ lastSync: new Date().toISOString()
811
+ }, null, 2));
812
+ }
813
+ //# sourceMappingURL=container-runner.js.map