daemora 1.0.3 → 1.0.5

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 (121) hide show
  1. package/LICENSE +663 -0
  2. package/README.md +69 -19
  3. package/SOUL.md +25 -24
  4. package/daemora-ui/README.md +11 -0
  5. package/package.json +12 -2
  6. package/skills/api-development.md +35 -0
  7. package/skills/artifacts-builder/SKILL.md +74 -0
  8. package/skills/artifacts-builder/scripts/bundle-artifact.sh +54 -0
  9. package/skills/artifacts-builder/scripts/init-artifact.sh +322 -0
  10. package/skills/artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  11. package/skills/brand-guidelines.md +73 -0
  12. package/skills/browser.md +77 -0
  13. package/skills/changelog-generator.md +104 -0
  14. package/skills/coding.md +26 -10
  15. package/skills/content-research-writer.md +538 -0
  16. package/skills/data-analysis.md +27 -0
  17. package/skills/debugging.md +33 -0
  18. package/skills/devops.md +37 -0
  19. package/skills/document-docx.md +197 -0
  20. package/skills/document-pdf.md +294 -0
  21. package/skills/document-pptx.md +484 -0
  22. package/skills/document-xlsx.md +289 -0
  23. package/skills/domain-name-brainstormer.md +212 -0
  24. package/skills/file-organizer.md +433 -0
  25. package/skills/frontend-design.md +42 -0
  26. package/skills/image-enhancer.md +99 -0
  27. package/skills/invoice-organizer.md +446 -0
  28. package/skills/lead-research-assistant.md +199 -0
  29. package/skills/mcp-builder/SKILL.md +328 -0
  30. package/skills/mcp-builder/reference/evaluation.md +602 -0
  31. package/skills/mcp-builder/reference/mcp_best_practices.md +915 -0
  32. package/skills/mcp-builder/reference/node_mcp_server.md +916 -0
  33. package/skills/mcp-builder/reference/python_mcp_server.md +752 -0
  34. package/skills/mcp-builder/scripts/connections.py +151 -0
  35. package/skills/mcp-builder/scripts/evaluation.py +373 -0
  36. package/skills/mcp-builder/scripts/example_evaluation.xml +22 -0
  37. package/skills/mcp-builder/scripts/requirements.txt +2 -0
  38. package/skills/meeting-insights-analyzer.md +327 -0
  39. package/skills/orchestration.md +93 -0
  40. package/skills/raffle-winner-picker.md +159 -0
  41. package/skills/slack-gif-creator/SKILL.md +646 -0
  42. package/skills/slack-gif-creator/core/color_palettes.py +302 -0
  43. package/skills/slack-gif-creator/core/easing.py +230 -0
  44. package/skills/slack-gif-creator/core/frame_composer.py +469 -0
  45. package/skills/slack-gif-creator/core/gif_builder.py +246 -0
  46. package/skills/slack-gif-creator/core/typography.py +357 -0
  47. package/skills/slack-gif-creator/core/validators.py +264 -0
  48. package/skills/slack-gif-creator/core/visual_effects.py +494 -0
  49. package/skills/slack-gif-creator/requirements.txt +4 -0
  50. package/skills/slack-gif-creator/templates/bounce.py +106 -0
  51. package/skills/slack-gif-creator/templates/explode.py +331 -0
  52. package/skills/slack-gif-creator/templates/fade.py +329 -0
  53. package/skills/slack-gif-creator/templates/flip.py +291 -0
  54. package/skills/slack-gif-creator/templates/kaleidoscope.py +211 -0
  55. package/skills/slack-gif-creator/templates/morph.py +329 -0
  56. package/skills/slack-gif-creator/templates/move.py +293 -0
  57. package/skills/slack-gif-creator/templates/pulse.py +268 -0
  58. package/skills/slack-gif-creator/templates/shake.py +127 -0
  59. package/skills/slack-gif-creator/templates/slide.py +291 -0
  60. package/skills/slack-gif-creator/templates/spin.py +269 -0
  61. package/skills/slack-gif-creator/templates/wiggle.py +300 -0
  62. package/skills/slack-gif-creator/templates/zoom.py +312 -0
  63. package/skills/system-admin.md +44 -0
  64. package/skills/tailored-resume-generator.md +345 -0
  65. package/skills/theme-factory/SKILL.md +59 -0
  66. package/skills/theme-factory/theme-showcase.pdf +0 -0
  67. package/skills/theme-factory/themes/arctic-frost.md +19 -0
  68. package/skills/theme-factory/themes/botanical-garden.md +19 -0
  69. package/skills/theme-factory/themes/desert-rose.md +19 -0
  70. package/skills/theme-factory/themes/forest-canopy.md +19 -0
  71. package/skills/theme-factory/themes/golden-hour.md +19 -0
  72. package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
  73. package/skills/theme-factory/themes/modern-minimalist.md +19 -0
  74. package/skills/theme-factory/themes/ocean-depths.md +19 -0
  75. package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
  76. package/skills/theme-factory/themes/tech-innovation.md +19 -0
  77. package/skills/video-downloader.md +99 -0
  78. package/skills/web-development.md +32 -0
  79. package/skills/webapp-testing/SKILL.md +96 -0
  80. package/skills/webapp-testing/examples/console_logging.py +35 -0
  81. package/skills/webapp-testing/examples/element_discovery.py +40 -0
  82. package/skills/webapp-testing/examples/static_html_automation.py +33 -0
  83. package/skills/webapp-testing/scripts/with_server.py +106 -0
  84. package/src/agents/SubAgentManager.js +57 -12
  85. package/src/api/openai-compat.js +212 -0
  86. package/src/channels/TelegramChannel.js +5 -2
  87. package/src/channels/index.js +7 -10
  88. package/src/cli.js +129 -50
  89. package/src/config/agentProfiles.js +1 -0
  90. package/src/config/default.js +10 -0
  91. package/src/config/models.js +317 -71
  92. package/src/config/permissions.js +12 -0
  93. package/src/core/AgentLoop.js +70 -50
  94. package/src/core/Compaction.js +84 -2
  95. package/src/core/MessageQueue.js +90 -0
  96. package/src/core/Task.js +13 -0
  97. package/src/core/TaskQueue.js +1 -1
  98. package/src/core/TaskRunner.js +80 -5
  99. package/src/index.js +328 -48
  100. package/src/mcp/MCPAgentRunner.js +48 -11
  101. package/src/mcp/MCPManager.js +40 -2
  102. package/src/models/ModelRouter.js +67 -1
  103. package/src/safety/DockerSandbox.js +212 -0
  104. package/src/safety/ExecApproval.js +118 -0
  105. package/src/scheduler/Heartbeat.js +56 -21
  106. package/src/services/cleanup.js +106 -0
  107. package/src/services/sessions.js +39 -1
  108. package/src/setup/wizard.js +75 -4
  109. package/src/skills/SkillLoader.js +104 -17
  110. package/src/storage/TaskStore.js +19 -1
  111. package/src/systemPrompt.js +171 -328
  112. package/src/tools/browserAutomation.js +615 -104
  113. package/src/tools/executeCommand.js +19 -1
  114. package/src/tools/index.js +6 -0
  115. package/src/tools/manageAgents.js +55 -4
  116. package/src/tools/replyWithFile.js +62 -0
  117. package/src/tools/screenCapture.js +12 -1
  118. package/src/tools/taskManager.js +164 -0
  119. package/src/tools/useMCP.js +3 -1
  120. package/src/utils/Embeddings.js +157 -10
  121. package/src/webhooks/WebhookHandler.js +107 -0
@@ -1,5 +1,6 @@
1
1
  import { spawnSubAgent } from "../agents/SubAgentManager.js";
2
2
  import mcpManager from "./MCPManager.js";
3
+ import { createSession, getSession, setMessages } from "../services/sessions.js";
3
4
 
4
5
  /**
5
6
  * MCP Agent Runner - spawns specialist sub-agents for individual MCP servers.
@@ -69,6 +70,8 @@ All MCP tool params must be passed as a single JSON string (the first and only a
69
70
  * @returns {Promise<string>} - Agent's final response
70
71
  */
71
72
  export async function runMCPAgent(serverName, taskDescription, options = {}) {
73
+ const { mainSessionId, ...restOptions } = options;
74
+
72
75
  // Get only this server's tool functions
73
76
  const serverTools = mcpManager.getServerTools(serverName);
74
77
 
@@ -80,33 +83,67 @@ export async function runMCPAgent(serverName, taskDescription, options = {}) {
80
83
  return `MCP server "${serverName}" not found or has no tools. Available servers: ${available.join(", ")}`;
81
84
  }
82
85
 
83
- // Build tool docs for this server's system prompt
86
+ // Build tool docs for this server's system prompt (include nested schemas)
84
87
  const toolDocs = Object.keys(serverTools)
85
88
  .map((fullName) => {
86
89
  const entry = mcpManager.toolMap.get(fullName);
87
90
  if (!entry) return `### ${fullName}(argsJson: string)`;
88
- const schema = entry.inputSchema?.properties || {};
89
- const required = entry.inputSchema?.required || [];
90
- const params = Object.entries(schema)
91
- .map(([k, v]) => `${k}${required.includes(k) ? "" : "?"}: ${v.type || "any"}`)
92
- .join(", ");
93
91
  const desc = entry.description || entry.toolName;
94
- const paramLine = params ? `\n- argsJson: \`{${params}}\`` : "";
95
- return `### ${fullName}(argsJson: string)\n${desc}${paramLine}`;
92
+ const schema = entry.inputSchema;
93
+ // Show full JSON schema so the sub-agent knows exact field names
94
+ let schemaDoc = "";
95
+ if (schema) {
96
+ try {
97
+ schemaDoc = "\n- Schema:\n```json\n" + JSON.stringify(schema, null, 2) + "\n```";
98
+ } catch {
99
+ const props = schema.properties || {};
100
+ const required = schema.required || [];
101
+ const params = Object.entries(props)
102
+ .map(([k, v]) => `${k}${required.includes(k) ? "" : "?"}: ${v.type || "any"}`)
103
+ .join(", ");
104
+ schemaDoc = params ? `\n- argsJson: \`{${params}}\`` : "";
105
+ }
106
+ }
107
+ return `### ${fullName}(argsJson: string)\n${desc}${schemaDoc}`;
96
108
  })
97
109
  .join("\n\n");
98
110
 
99
111
  const systemPromptOverride = buildMCPAgentSystemPrompt(serverName, toolDocs);
100
112
 
113
+ // Load sub-agent session history (persistent across calls)
114
+ const subSessionId = mainSessionId ? `${mainSessionId}--${serverName}` : null;
115
+ let historyMessages = [];
116
+ if (subSessionId) {
117
+ const subSession = getSession(subSessionId);
118
+ if (subSession && subSession.messages.length > 0) {
119
+ historyMessages = subSession.messages.map(m => ({ role: m.role, content: m.content }));
120
+ console.log(`[MCPAgentRunner] Loaded ${historyMessages.length} history messages for "${serverName}"`);
121
+ }
122
+ }
123
+
101
124
  console.log(
102
125
  `[MCPAgentRunner] Spawning specialist for "${serverName}" (${Object.keys(serverTools).length} tools)`
103
126
  );
104
127
 
105
- return spawnSubAgent(taskDescription, {
106
- ...options,
128
+ const fullResult = await spawnSubAgent(taskDescription, {
129
+ ...restOptions,
107
130
  toolOverride: serverTools,
108
131
  systemPromptOverride,
109
- // MCP agents are always depth 1 - they don't spawn further sub-agents
110
132
  depth: 1,
133
+ historyMessages,
134
+ returnFullResult: true,
111
135
  });
136
+
137
+ // Save sub-agent session (cap at 100 messages)
138
+ if (subSessionId && fullResult.messages) {
139
+ let subSession = getSession(subSessionId);
140
+ if (!subSession) subSession = createSession(subSessionId);
141
+ const capped = fullResult.messages.length > 100
142
+ ? fullResult.messages.slice(-100)
143
+ : fullResult.messages;
144
+ setMessages(subSessionId, capped);
145
+ console.log(`[MCPAgentRunner] Saved ${capped.length} messages to sub-session "${subSessionId}"`);
146
+ }
147
+
148
+ return typeof fullResult === "string" ? fullResult : fullResult.text;
112
149
  }
@@ -192,9 +192,47 @@ class MCPManager {
192
192
 
193
193
  const servers = mcpConfig.mcpServers || {};
194
194
 
195
- // Filter to only real, enabled servers
195
+ // Filter to only real, enabled servers with valid auth
196
196
  const enabledServers = Object.entries(servers).filter(
197
- ([name, cfg]) => !name.startsWith("_comment") && typeof cfg === "object" && cfg.enabled !== false
197
+ ([name, cfg]) => {
198
+ if (name.startsWith("_comment") || typeof cfg !== "object" || cfg.enabled === false) return false;
199
+
200
+ // Validate auth: skip servers with placeholder or empty credentials
201
+ if (cfg.env) {
202
+ const hasPlaceholder = Object.entries(cfg.env).some(([, v]) =>
203
+ typeof v === "string" && (
204
+ v === "" ||
205
+ v.startsWith("YOUR_") ||
206
+ v === "your-token-here" ||
207
+ v === "your-key-here" ||
208
+ /^[A-Z_]+$/.test(v) // e.g. "GITHUB_TOKEN" as a value, not a reference
209
+ )
210
+ );
211
+ if (hasPlaceholder) {
212
+ console.log(`[MCPManager] Skipping "${name}" - env vars contain placeholder/empty values. Set real credentials.`);
213
+ return false;
214
+ }
215
+ }
216
+
217
+ if (cfg.headers) {
218
+ const expandedHeaders = Object.entries(cfg.headers).map(([k, v]) => {
219
+ if (typeof v === "string") {
220
+ return [k, v.replace(/\$\{([^}]+)\}/g, (_, envName) => process.env[envName] ?? "")];
221
+ }
222
+ return [k, v];
223
+ });
224
+ const hasEmpty = expandedHeaders.some(([, v]) => typeof v === "string" && v.trim() === "");
225
+ const hasPlaceholder = expandedHeaders.some(([, v]) =>
226
+ typeof v === "string" && (v.includes("YOUR_") || v === "Bearer " || v === "Bearer")
227
+ );
228
+ if (hasEmpty || hasPlaceholder) {
229
+ console.log(`[MCPManager] Skipping "${name}" - headers resolve to empty/placeholder values. Set env vars.`);
230
+ return false;
231
+ }
232
+ }
233
+
234
+ return true;
235
+ }
198
236
  );
199
237
 
200
238
  if (enabledServers.length === 0) {
@@ -48,7 +48,28 @@ function getProvider(name, apiKeys = {}) {
48
48
  * @returns {{ model: object, meta: object }} AI SDK model instance + metadata
49
49
  */
50
50
  export function getModel(modelId, apiKeys = {}) {
51
- const meta = models[modelId];
51
+ let meta = models[modelId];
52
+
53
+ // Passthrough: if model isn't in the registry but follows "provider:model" format,
54
+ // create a dynamic entry so new models work without updating the registry.
55
+ if (!meta && modelId.includes(":")) {
56
+ const [providerName, modelName] = modelId.split(":", 2);
57
+ const knownProviders = ["openai", "anthropic", "google", "ollama"];
58
+ if (knownProviders.includes(providerName)) {
59
+ console.log(`[ModelRouter] Model "${modelId}" not in registry — using dynamic passthrough`);
60
+ meta = {
61
+ provider: providerName,
62
+ model: modelName,
63
+ contextWindow: 128_000,
64
+ compactAt: 90_000,
65
+ costPer1kInput: 0.001,
66
+ costPer1kOutput: 0.004,
67
+ capabilities: ["text", "tools"],
68
+ tier: "standard",
69
+ };
70
+ }
71
+ }
72
+
52
73
  if (!meta) {
53
74
  throw new Error(`Unknown model: ${modelId}. Available: ${Object.keys(models).join(", ")}`);
54
75
  }
@@ -138,6 +159,51 @@ export function listAvailableModels() {
138
159
  return available;
139
160
  }
140
161
 
162
+ // ── Thinking Level Resolution ──────────────────────────────────────────────────
163
+
164
+ const _thinkingAliases = { on: "low", min: "minimal", max: "high" };
165
+
166
+ /**
167
+ * Resolve thinking config for a model + level combination.
168
+ * Returns params to merge into the generateObject call, or null if no thinking.
169
+ *
170
+ * @param {string} modelId - e.g. "anthropic:claude-sonnet-4-6"
171
+ * @param {string} level - "auto"|"off"|"minimal"|"low"|"medium"|"high"|"xhigh"
172
+ * @returns {{ thinkingParams: object } | null}
173
+ */
174
+ export function resolveThinkingConfig(modelId, level = "auto") {
175
+ const normalized = _thinkingAliases[level] || level;
176
+ if (normalized === "off" || normalized === "auto") return null;
177
+
178
+ const provider = modelId.split(":")[0];
179
+
180
+ // Anthropic: thinking.budget_tokens
181
+ if (provider === "anthropic") {
182
+ const budgetMap = { minimal: 1024, low: 2048, medium: 4096, high: 8192, xhigh: 16384 };
183
+ const budget = budgetMap[normalized];
184
+ if (!budget) return null;
185
+ return { thinkingParams: { thinking: { type: "enabled", budgetTokens: budget } } };
186
+ }
187
+
188
+ // OpenAI: reasoning.effort (only for o-series models)
189
+ if (provider === "openai") {
190
+ const effortMap = { minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "high" };
191
+ const effort = effortMap[normalized];
192
+ if (!effort) return null;
193
+ return { thinkingParams: { reasoning: { effort } } };
194
+ }
195
+
196
+ // Google: thinkingConfig.thinkingBudget
197
+ if (provider === "google") {
198
+ const budgetMap = { minimal: 1024, low: 2048, medium: 4096, high: 8192, xhigh: 16384 };
199
+ const budget = budgetMap[normalized];
200
+ if (!budget) return null;
201
+ return { thinkingParams: { thinkingConfig: { thinkingBudget: budget } } };
202
+ }
203
+
204
+ return null;
205
+ }
206
+
141
207
  // ── Task-Type Model Routing ────────────────────────────────────────────────────
142
208
 
143
209
  const _profileEnvMap = {
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Docker Sandbox — run commands inside Docker containers for kernel-level isolation.
3
+ *
4
+ * Config:
5
+ * SANDBOX_MODE=docker — enable Docker isolation
6
+ * DOCKER_IMAGE=node:22-slim — base image
7
+ * DOCKER_MEMORY=512m — memory limit
8
+ * DOCKER_CPUS=0.5 — CPU limit
9
+ * DOCKER_NETWORK=none — network mode (none = no network)
10
+ * DOCKER_SCOPE=session — "session" (per session) | "shared" (one for all)
11
+ */
12
+
13
+ import { execSync, spawnSync } from "node:child_process";
14
+ import { config } from "../config/default.js";
15
+
16
+ // containerId → { scope, createdAt, lastUsedAt }
17
+ const _containers = new Map();
18
+ const CLEANUP_AFTER = 10 * 60 * 1000; // 10 min inactivity
19
+
20
+ class DockerSandbox {
21
+ constructor() {
22
+ this._available = null; // cached Docker availability check
23
+ }
24
+
25
+ /**
26
+ * Check if Docker is available.
27
+ */
28
+ isAvailable() {
29
+ if (this._available !== null) return this._available;
30
+ try {
31
+ execSync("docker info", { stdio: "pipe", timeout: 5000 });
32
+ this._available = true;
33
+ } catch {
34
+ this._available = false;
35
+ }
36
+ return this._available;
37
+ }
38
+
39
+ /**
40
+ * Ensure a container exists for the given scope.
41
+ * @param {string} scopeId — session ID or "shared"
42
+ * @returns {string} containerId
43
+ */
44
+ ensureContainer(scopeId = "shared") {
45
+ const existing = _containers.get(scopeId);
46
+ if (existing) {
47
+ // Check if container is still running
48
+ try {
49
+ const status = execSync(`docker inspect -f '{{.State.Running}}' ${existing.containerId}`, {
50
+ encoding: "utf-8",
51
+ stdio: ["pipe", "pipe", "pipe"],
52
+ timeout: 5000,
53
+ }).trim();
54
+ if (status === "true") {
55
+ existing.lastUsedAt = Date.now();
56
+ return existing.containerId;
57
+ }
58
+ } catch {
59
+ // Container gone — remove from map and create new
60
+ _containers.delete(scopeId);
61
+ }
62
+ }
63
+
64
+ const sandbox = config.sandbox || {};
65
+ const image = sandbox.dockerImage || "node:22-slim";
66
+ const memory = sandbox.dockerMemory || "512m";
67
+ const cpus = sandbox.dockerCpus || "0.5";
68
+ const network = sandbox.dockerNetwork || "none";
69
+
70
+ // Create container with security constraints
71
+ const containerName = `daemora-sandbox-${scopeId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 30)}-${Date.now()}`;
72
+
73
+ const args = [
74
+ "docker", "run", "-d",
75
+ "--name", containerName,
76
+ "--memory", memory,
77
+ "--cpus", cpus,
78
+ "--network", network,
79
+ "--read-only",
80
+ "--cap-drop", "ALL",
81
+ "--tmpfs", "/tmp:rw,noexec,nosuid,size=100m",
82
+ "--tmpfs", "/workspace:rw,size=500m",
83
+ "-w", "/workspace",
84
+ image,
85
+ "tail", "-f", "/dev/null", // Keep container alive
86
+ ];
87
+
88
+ try {
89
+ const containerId = execSync(args.join(" "), {
90
+ encoding: "utf-8",
91
+ timeout: 30000,
92
+ }).trim();
93
+
94
+ _containers.set(scopeId, {
95
+ containerId,
96
+ containerName,
97
+ createdAt: Date.now(),
98
+ lastUsedAt: Date.now(),
99
+ });
100
+
101
+ console.log(`[DockerSandbox] Created container ${containerName} (${containerId.slice(0, 12)}) for scope: ${scopeId}`);
102
+ return containerId;
103
+ } catch (error) {
104
+ throw new Error(`Failed to create Docker container: ${error.message}`);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Execute a command inside a container.
110
+ * @param {string} scopeId
111
+ * @param {string} command
112
+ * @param {object} opts — { timeout, cwd }
113
+ * @returns {string} command output
114
+ */
115
+ exec(scopeId, command, opts = {}) {
116
+ const containerId = this.ensureContainer(scopeId);
117
+ const timeout = opts.timeout || 120_000;
118
+ const cwd = opts.cwd || "/workspace";
119
+
120
+ try {
121
+ const result = execSync(`docker exec -w "${cwd}" ${containerId} sh -c ${JSON.stringify(command)}`, {
122
+ encoding: "utf-8",
123
+ timeout,
124
+ maxBuffer: 10 * 1024 * 1024,
125
+ stdio: ["pipe", "pipe", "pipe"],
126
+ });
127
+
128
+ const entry = _containers.get(scopeId);
129
+ if (entry) entry.lastUsedAt = Date.now();
130
+
131
+ return result || "(command completed with no output)";
132
+ } catch (error) {
133
+ if (error.killed) {
134
+ return `Command timed out after ${timeout / 1000}s inside Docker container.`;
135
+ }
136
+ const parts = [];
137
+ if (error.stdout) parts.push(`stdout:\n${error.stdout.slice(0, 2000)}`);
138
+ if (error.stderr) parts.push(`stderr:\n${error.stderr.slice(0, 2000)}`);
139
+ const exitMsg = error.status !== undefined ? ` (exit code: ${error.status})` : "";
140
+ if (parts.length > 0) return `Command failed${exitMsg}:\n${parts.join("\n---\n")}`;
141
+ return `Command failed${exitMsg}: ${error.message}`;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Copy workspace files into the container.
147
+ */
148
+ copyToContainer(scopeId, hostPath, containerPath = "/workspace") {
149
+ const containerId = this.ensureContainer(scopeId);
150
+ try {
151
+ execSync(`docker cp "${hostPath}/." ${containerId}:${containerPath}`, {
152
+ timeout: 30000,
153
+ stdio: "pipe",
154
+ });
155
+ return true;
156
+ } catch (error) {
157
+ console.log(`[DockerSandbox] Copy failed: ${error.message}`);
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Remove a container.
164
+ */
165
+ removeContainer(scopeId) {
166
+ const entry = _containers.get(scopeId);
167
+ if (!entry) return;
168
+ try {
169
+ execSync(`docker rm -f ${entry.containerId}`, { stdio: "pipe", timeout: 10000 });
170
+ console.log(`[DockerSandbox] Removed container for scope: ${scopeId}`);
171
+ } catch {}
172
+ _containers.delete(scopeId);
173
+ }
174
+
175
+ /**
176
+ * Cleanup inactive containers.
177
+ */
178
+ cleanup() {
179
+ const now = Date.now();
180
+ for (const [scopeId, entry] of _containers.entries()) {
181
+ if (now - entry.lastUsedAt > CLEANUP_AFTER) {
182
+ this.removeContainer(scopeId);
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Remove all containers.
189
+ */
190
+ removeAll() {
191
+ for (const scopeId of [..._containers.keys()]) {
192
+ this.removeContainer(scopeId);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * List active containers.
198
+ */
199
+ list() {
200
+ return [..._containers.entries()].map(([scopeId, entry]) => ({
201
+ scopeId,
202
+ containerId: entry.containerId.slice(0, 12),
203
+ containerName: entry.containerName,
204
+ createdAt: new Date(entry.createdAt).toISOString(),
205
+ lastUsedAt: new Date(entry.lastUsedAt).toISOString(),
206
+ idleMs: Date.now() - entry.lastUsedAt,
207
+ }));
208
+ }
209
+ }
210
+
211
+ const dockerSandbox = new DockerSandbox();
212
+ export default dockerSandbox;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Exec Approval Manager — interactive approval gates for dangerous commands.
3
+ *
4
+ * When approval mode is enabled and a command matches dangerous patterns,
5
+ * the agent loop pauses and waits for user approval via the API.
6
+ *
7
+ * Config: approval.mode = "off" | "dangerous-only" | "all"
8
+ * API:
9
+ * GET /api/approvals — list pending approvals
10
+ * POST /api/approvals/:id — approve/deny { decision: "allow" | "allow-once" | "deny" }
11
+ */
12
+
13
+ import { v4 as uuidv4 } from "uuid";
14
+
15
+ // Dangerous command patterns
16
+ const DANGEROUS_PATTERNS = [
17
+ /\brm\s+(-rf?|--recursive)\b/i,
18
+ /\bsudo\b/,
19
+ /\bdrop\s+(table|database)\b/i,
20
+ /\bkill\s+-9\b/,
21
+ /\bshutdown\b/,
22
+ /\breboot\b/,
23
+ /\bmkfs\b/,
24
+ /\bdd\s+if=/,
25
+ /\bcurl\b.*\|\s*(sh|bash|zsh)\b/,
26
+ /\bwget\b.*\|\s*(sh|bash|zsh)\b/,
27
+ /\bnpm\s+publish\b/,
28
+ /\bgit\s+push\s+.*--force\b/,
29
+ /\bgit\s+reset\s+--hard\b/,
30
+ /\bdocker\s+rm\b/,
31
+ /\bdocker\s+system\s+prune\b/,
32
+ ];
33
+
34
+ class ExecApprovalManager {
35
+ constructor() {
36
+ // approvalId → { command, taskId, createdAt, resolve, timer }
37
+ this._pending = new Map();
38
+ this._timeoutMs = 60_000; // 60 seconds default
39
+ this._mode = process.env.APPROVAL_MODE || "off"; // "off" | "dangerous-only" | "all"
40
+ }
41
+
42
+ get mode() {
43
+ return this._mode;
44
+ }
45
+
46
+ /**
47
+ * Check if a command needs approval.
48
+ */
49
+ needsApproval(command) {
50
+ if (this._mode === "off") return false;
51
+ if (this._mode === "all") return true;
52
+ // "dangerous-only" — check against patterns
53
+ return DANGEROUS_PATTERNS.some(p => p.test(command));
54
+ }
55
+
56
+ /**
57
+ * Request approval for a command. Returns a Promise that resolves with the decision.
58
+ * @param {string} command
59
+ * @param {string} taskId
60
+ * @returns {Promise<"allow"|"deny">}
61
+ */
62
+ requestApproval(command, taskId) {
63
+ return new Promise((resolve) => {
64
+ const approvalId = uuidv4().slice(0, 12);
65
+
66
+ const timer = setTimeout(() => {
67
+ this._pending.delete(approvalId);
68
+ console.log(`[ExecApproval] Timeout for ${approvalId} — denying`);
69
+ resolve("deny");
70
+ }, this._timeoutMs);
71
+
72
+ this._pending.set(approvalId, {
73
+ command,
74
+ taskId,
75
+ createdAt: new Date().toISOString(),
76
+ resolve,
77
+ timer,
78
+ });
79
+
80
+ console.log(`[ExecApproval] Waiting for approval ${approvalId}: "${command.slice(0, 80)}"`);
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Resolve a pending approval.
86
+ * @param {string} approvalId
87
+ * @param {"allow"|"allow-once"|"deny"} decision
88
+ * @returns {boolean} true if found and resolved
89
+ */
90
+ resolveApproval(approvalId, decision) {
91
+ const entry = this._pending.get(approvalId);
92
+ if (!entry) return false;
93
+
94
+ clearTimeout(entry.timer);
95
+ this._pending.delete(approvalId);
96
+
97
+ const effective = decision === "allow-once" ? "allow" : decision;
98
+ console.log(`[ExecApproval] ${approvalId} → ${decision}`);
99
+ entry.resolve(effective);
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * List all pending approvals (for API).
105
+ */
106
+ listPending() {
107
+ return [...this._pending.entries()].map(([id, entry]) => ({
108
+ id,
109
+ command: entry.command,
110
+ taskId: entry.taskId,
111
+ createdAt: entry.createdAt,
112
+ expiresIn: Math.max(0, this._timeoutMs - (Date.now() - new Date(entry.createdAt).getTime())),
113
+ }));
114
+ }
115
+ }
116
+
117
+ const execApproval = new ExecApprovalManager();
118
+ export default execApproval;
@@ -5,12 +5,15 @@ import taskQueue from "../core/TaskQueue.js";
5
5
  import eventBus from "../core/EventBus.js";
6
6
 
7
7
  /**
8
- * Heartbeat - periodic proactive check.
8
+ * Heartbeat - periodic proactive agent turns.
9
9
  *
10
- * Reads HEARTBEAT.md for user-defined checks.
11
- * Every N minutes, creates a task: "Check status per HEARTBEAT.md"
12
- * If nothing notable → "All clear" (no notification).
13
- * If something needs attention → sends result to configured channel.
10
+ * Reads HEARTBEAT.md for user-defined instructions. Every N minutes,
11
+ * enqueues a heartbeat task with the HEARTBEAT.md content as prompt.
12
+ *
13
+ * Features:
14
+ * - Active hours: skip runs outside configurable window (default 8-22)
15
+ * - Duplicate suppression: skip if HEARTBEAT.md unchanged within 24h
16
+ * - Configurable via env vars or config
14
17
  */
15
18
 
16
19
  class Heartbeat {
@@ -21,11 +24,14 @@ class Heartbeat {
21
24
  this.intervalMinutes = config.heartbeatIntervalMinutes;
22
25
  this.lastCheck = null;
23
26
  this.checkCount = 0;
27
+ this._lastContentHash = null;
28
+ this._lastContentAt = 0;
29
+
30
+ // Active hours config (env override or defaults)
31
+ this.activeHourStart = parseInt(process.env.HEARTBEAT_ACTIVE_START || "8", 10);
32
+ this.activeHourEnd = parseInt(process.env.HEARTBEAT_ACTIVE_END || "22", 10);
24
33
  }
25
34
 
26
- /**
27
- * Start the heartbeat.
28
- */
29
35
  start() {
30
36
  if (!config.daemonMode) {
31
37
  console.log(`[Heartbeat] Skipped - daemon mode not enabled`);
@@ -44,16 +50,13 @@ class Heartbeat {
44
50
  );
45
51
 
46
52
  console.log(
47
- `[Heartbeat] Started (every ${this.intervalMinutes} minutes)`
53
+ `[Heartbeat] Started (every ${this.intervalMinutes}min, active ${this.activeHourStart}:00-${this.activeHourEnd}:00)`
48
54
  );
49
55
 
50
56
  // Run first check after 1 minute
51
57
  setTimeout(() => this.check(), 60000);
52
58
  }
53
59
 
54
- /**
55
- * Stop the heartbeat.
56
- */
57
60
  stop() {
58
61
  this.running = false;
59
62
  if (this.timer) {
@@ -63,20 +66,53 @@ class Heartbeat {
63
66
  console.log(`[Heartbeat] Stopped`);
64
67
  }
65
68
 
66
- /**
67
- * Run a heartbeat check.
68
- */
69
+ _isActiveHour() {
70
+ const hour = new Date().getHours();
71
+ return hour >= this.activeHourStart && hour < this.activeHourEnd;
72
+ }
73
+
74
+ _simpleHash(str) {
75
+ let hash = 0;
76
+ for (let i = 0; i < str.length; i++) {
77
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
78
+ }
79
+ return hash;
80
+ }
81
+
69
82
  async check() {
70
83
  if (!this.running) return;
71
84
 
85
+ // Active hours check
86
+ if (!this._isActiveHour()) {
87
+ console.log(`[Heartbeat] Outside active hours (${this.activeHourStart}:00-${this.activeHourEnd}:00) — skipping`);
88
+ return;
89
+ }
90
+
91
+ if (!existsSync(this.heartbeatPath)) return;
92
+
72
93
  try {
73
- const instructions = readFileSync(this.heartbeatPath, "utf-8");
94
+ const instructions = readFileSync(this.heartbeatPath, "utf-8").trim();
95
+ if (!instructions) {
96
+ console.log(`[Heartbeat] HEARTBEAT.md is empty — skipping`);
97
+ return;
98
+ }
99
+
100
+ // Duplicate suppression: skip if same content within 24h
101
+ const contentHash = this._simpleHash(instructions);
102
+ const now = Date.now();
103
+ if (contentHash === this._lastContentHash && (now - this._lastContentAt) < 24 * 60 * 60 * 1000) {
104
+ console.log(`[Heartbeat] HEARTBEAT.md unchanged within 24h — skipping`);
105
+ return;
106
+ }
107
+ this._lastContentHash = contentHash;
108
+ this._lastContentAt = now;
109
+
74
110
  this.checkCount++;
75
111
  this.lastCheck = new Date().toISOString();
76
112
 
77
113
  console.log(`[Heartbeat] Check #${this.checkCount}...`);
78
114
 
79
- const prompt = `You are running a periodic heartbeat check. Review the following instructions and check each item. If everything looks fine, just respond "All clear." If something needs attention, describe what you found.
115
+ const prompt = `[Heartbeat check #${this.checkCount}] Follow the instructions in HEARTBEAT.md. If everything looks fine, respond "All clear." If something needs attention, describe what you found and take action.
80
116
 
81
117
  Instructions from HEARTBEAT.md:
82
118
  ${instructions}
@@ -86,8 +122,9 @@ Current time: ${new Date().toISOString()}`;
86
122
  taskQueue.enqueue({
87
123
  input: prompt,
88
124
  channel: "heartbeat",
89
- sessionId: null,
125
+ sessionId: "heartbeat",
90
126
  priority: 2,
127
+ type: "heartbeat",
91
128
  });
92
129
 
93
130
  eventBus.emitEvent("heartbeat:check", {
@@ -98,13 +135,11 @@ Current time: ${new Date().toISOString()}`;
98
135
  }
99
136
  }
100
137
 
101
- /**
102
- * Get stats.
103
- */
104
138
  stats() {
105
139
  return {
106
140
  running: this.running,
107
141
  intervalMinutes: this.intervalMinutes,
142
+ activeHours: `${this.activeHourStart}:00-${this.activeHourEnd}:00`,
108
143
  lastCheck: this.lastCheck,
109
144
  checkCount: this.checkCount,
110
145
  };