daemora 1.0.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 (115) hide show
  1. package/README.md +666 -0
  2. package/SOUL.md +104 -0
  3. package/config/hooks.json +14 -0
  4. package/config/mcp.json +145 -0
  5. package/package.json +86 -0
  6. package/skills/.gitkeep +0 -0
  7. package/skills/apple-notes.md +193 -0
  8. package/skills/apple-reminders.md +189 -0
  9. package/skills/camsnap.md +162 -0
  10. package/skills/coding.md +14 -0
  11. package/skills/documents.md +13 -0
  12. package/skills/email.md +13 -0
  13. package/skills/gif-search.md +196 -0
  14. package/skills/healthcheck.md +225 -0
  15. package/skills/image-gen.md +147 -0
  16. package/skills/model-usage.md +182 -0
  17. package/skills/obsidian.md +207 -0
  18. package/skills/pdf.md +211 -0
  19. package/skills/research.md +13 -0
  20. package/skills/skill-creator.md +142 -0
  21. package/skills/spotify.md +149 -0
  22. package/skills/summarize.md +230 -0
  23. package/skills/things.md +199 -0
  24. package/skills/tmux.md +204 -0
  25. package/skills/trello.md +183 -0
  26. package/skills/video-frames.md +202 -0
  27. package/skills/weather.md +127 -0
  28. package/src/a2a/A2AClient.js +136 -0
  29. package/src/a2a/A2AServer.js +316 -0
  30. package/src/a2a/AgentCard.js +79 -0
  31. package/src/agents/SubAgentManager.js +369 -0
  32. package/src/agents/Supervisor.js +192 -0
  33. package/src/channels/BaseChannel.js +104 -0
  34. package/src/channels/DiscordChannel.js +288 -0
  35. package/src/channels/EmailChannel.js +172 -0
  36. package/src/channels/GoogleChatChannel.js +316 -0
  37. package/src/channels/HttpChannel.js +26 -0
  38. package/src/channels/LineChannel.js +168 -0
  39. package/src/channels/SignalChannel.js +186 -0
  40. package/src/channels/SlackChannel.js +329 -0
  41. package/src/channels/TeamsChannel.js +272 -0
  42. package/src/channels/TelegramChannel.js +347 -0
  43. package/src/channels/WhatsAppChannel.js +219 -0
  44. package/src/channels/index.js +198 -0
  45. package/src/cli.js +1267 -0
  46. package/src/config/agentProfiles.js +120 -0
  47. package/src/config/channels.js +32 -0
  48. package/src/config/default.js +206 -0
  49. package/src/config/models.js +123 -0
  50. package/src/config/permissions.js +167 -0
  51. package/src/core/AgentLoop.js +446 -0
  52. package/src/core/Compaction.js +143 -0
  53. package/src/core/CostTracker.js +116 -0
  54. package/src/core/EventBus.js +46 -0
  55. package/src/core/Task.js +67 -0
  56. package/src/core/TaskQueue.js +206 -0
  57. package/src/core/TaskRunner.js +226 -0
  58. package/src/daemon/DaemonManager.js +301 -0
  59. package/src/hooks/HookRunner.js +230 -0
  60. package/src/index.js +482 -0
  61. package/src/mcp/MCPAgentRunner.js +112 -0
  62. package/src/mcp/MCPClient.js +186 -0
  63. package/src/mcp/MCPManager.js +412 -0
  64. package/src/models/ModelRouter.js +180 -0
  65. package/src/safety/AuditLog.js +135 -0
  66. package/src/safety/CircuitBreaker.js +126 -0
  67. package/src/safety/FilesystemGuard.js +169 -0
  68. package/src/safety/GitRollback.js +139 -0
  69. package/src/safety/HumanApproval.js +156 -0
  70. package/src/safety/InputSanitizer.js +72 -0
  71. package/src/safety/PermissionGuard.js +83 -0
  72. package/src/safety/Sandbox.js +70 -0
  73. package/src/safety/SecretScanner.js +100 -0
  74. package/src/safety/SecretVault.js +250 -0
  75. package/src/scheduler/Heartbeat.js +115 -0
  76. package/src/scheduler/Scheduler.js +228 -0
  77. package/src/services/models/outputSchema.js +15 -0
  78. package/src/services/openai.js +25 -0
  79. package/src/services/sessions.js +65 -0
  80. package/src/setup/theme.js +110 -0
  81. package/src/setup/wizard.js +788 -0
  82. package/src/skills/SkillLoader.js +168 -0
  83. package/src/storage/TaskStore.js +69 -0
  84. package/src/systemPrompt.js +526 -0
  85. package/src/tenants/TenantContext.js +19 -0
  86. package/src/tenants/TenantManager.js +379 -0
  87. package/src/tools/ToolRegistry.js +141 -0
  88. package/src/tools/applyPatch.js +144 -0
  89. package/src/tools/browserAutomation.js +223 -0
  90. package/src/tools/createDocument.js +265 -0
  91. package/src/tools/cronTool.js +105 -0
  92. package/src/tools/editFile.js +139 -0
  93. package/src/tools/executeCommand.js +123 -0
  94. package/src/tools/glob.js +67 -0
  95. package/src/tools/grep.js +121 -0
  96. package/src/tools/imageAnalysis.js +120 -0
  97. package/src/tools/index.js +173 -0
  98. package/src/tools/listDirectory.js +47 -0
  99. package/src/tools/manageAgents.js +47 -0
  100. package/src/tools/manageMCP.js +159 -0
  101. package/src/tools/memory.js +478 -0
  102. package/src/tools/messageChannel.js +45 -0
  103. package/src/tools/projectTracker.js +259 -0
  104. package/src/tools/readFile.js +52 -0
  105. package/src/tools/screenCapture.js +112 -0
  106. package/src/tools/searchContent.js +76 -0
  107. package/src/tools/searchFiles.js +75 -0
  108. package/src/tools/sendEmail.js +118 -0
  109. package/src/tools/sendFile.js +63 -0
  110. package/src/tools/textToSpeech.js +161 -0
  111. package/src/tools/transcribeAudio.js +82 -0
  112. package/src/tools/useMCP.js +29 -0
  113. package/src/tools/webFetch.js +150 -0
  114. package/src/tools/webSearch.js +134 -0
  115. package/src/tools/writeFile.js +26 -0
@@ -0,0 +1,116 @@
1
+ import { readFileSync, existsSync, appendFileSync, mkdirSync } from "fs";
2
+ import { config } from "../config/default.js";
3
+ import { models } from "../config/models.js";
4
+ import eventBus from "./EventBus.js";
5
+ import tenantContext from "../tenants/TenantContext.js";
6
+
7
+ const COSTS_DIR = config.costsDir;
8
+ mkdirSync(COSTS_DIR, { recursive: true });
9
+
10
+ /**
11
+ * Get today's cost log file path.
12
+ */
13
+ function getTodayLogPath() {
14
+ const today = new Date().toISOString().split("T")[0];
15
+ return `${COSTS_DIR}/${today}.jsonl`;
16
+ }
17
+
18
+ /**
19
+ * Log a cost entry.
20
+ * tenantId is automatically added from TenantContext when available.
21
+ */
22
+ export function logCost({ taskId, modelId, inputTokens, outputTokens, estimatedCost, tenantId = null }) {
23
+ const entry = {
24
+ timestamp: new Date().toISOString(),
25
+ taskId,
26
+ modelId,
27
+ inputTokens,
28
+ outputTokens,
29
+ estimatedCost,
30
+ tenantId,
31
+ };
32
+ appendFileSync(getTodayLogPath(), JSON.stringify(entry) + "\n");
33
+ }
34
+
35
+ /**
36
+ * Get total cost spent today (global — all tenants combined).
37
+ */
38
+ export function getTodayCost() {
39
+ const logPath = getTodayLogPath();
40
+ if (!existsSync(logPath)) return 0;
41
+
42
+ const lines = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean);
43
+ return lines.reduce((sum, line) => {
44
+ try {
45
+ const entry = JSON.parse(line);
46
+ return sum + (entry.estimatedCost || 0);
47
+ } catch {
48
+ return sum;
49
+ }
50
+ }, 0);
51
+ }
52
+
53
+ /**
54
+ * Get total cost spent today for a specific tenant.
55
+ *
56
+ * @param {string} tenantId
57
+ * @returns {number}
58
+ */
59
+ export function getTenantTodayCost(tenantId) {
60
+ if (!tenantId) return 0;
61
+ const logPath = getTodayLogPath();
62
+ if (!existsSync(logPath)) return 0;
63
+
64
+ const lines = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean);
65
+ return lines.reduce((sum, line) => {
66
+ try {
67
+ const entry = JSON.parse(line);
68
+ if (entry.tenantId !== tenantId) return sum;
69
+ return sum + (entry.estimatedCost || 0);
70
+ } catch {
71
+ return sum;
72
+ }
73
+ }, 0);
74
+ }
75
+
76
+ /**
77
+ * Check if global daily budget is exceeded.
78
+ */
79
+ export function isDailyBudgetExceeded() {
80
+ return getTodayCost() >= config.maxDailyCost;
81
+ }
82
+
83
+ /**
84
+ * Check if a specific tenant's daily budget is exceeded.
85
+ *
86
+ * @param {string} tenantId
87
+ * @param {number} maxDailyCost
88
+ * @returns {boolean}
89
+ */
90
+ export function isTenantDailyBudgetExceeded(tenantId, maxDailyCost) {
91
+ if (!tenantId || !maxDailyCost) return false;
92
+ return getTenantTodayCost(tenantId) >= maxDailyCost;
93
+ }
94
+
95
+ /**
96
+ * Estimate cost for a model call.
97
+ */
98
+ export function estimateCost(modelId, inputTokens, outputTokens) {
99
+ const meta = models[modelId];
100
+ if (!meta) return 0;
101
+ return (inputTokens / 1000) * meta.costPer1kInput + (outputTokens / 1000) * meta.costPer1kOutput;
102
+ }
103
+
104
+ // Auto-log costs from EventBus — includes tenantId from TenantContext
105
+ eventBus.on("model:called", (data) => {
106
+ const tenantId = tenantContext.getStore()?.tenant?.id || null;
107
+ const cost = estimateCost(data.modelId, data.inputTokens || 0, data.outputTokens || 0);
108
+ logCost({
109
+ taskId: data.taskId || "unknown",
110
+ modelId: data.modelId,
111
+ inputTokens: data.inputTokens || 0,
112
+ outputTokens: data.outputTokens || 0,
113
+ estimatedCost: cost,
114
+ tenantId,
115
+ });
116
+ });
@@ -0,0 +1,46 @@
1
+ import { EventEmitter } from "events";
2
+
3
+ /**
4
+ * Global event bus for inter-module communication.
5
+ *
6
+ * Events:
7
+ * task:created — new task enqueued
8
+ * task:started — task picked up by runner
9
+ * task:completed — task finished successfully
10
+ * task:failed — task failed
11
+ * tool:before — about to execute a tool (PreToolUse hook point)
12
+ * tool:after — tool execution finished (PostToolUse hook point)
13
+ * tool:blocked — tool call was blocked by safety
14
+ * agent:spawned — sub-agent created
15
+ * agent:finished — sub-agent completed
16
+ * agent:killed — sub-agent terminated by supervisor
17
+ * model:called — LLM API call made (for cost tracking)
18
+ * compact:triggered — context compaction started
19
+ * memory:written — memory entry added
20
+ * secret:detected — secret found and redacted
21
+ * audit:event — generic audit event
22
+ */
23
+ class AgentEventBus extends EventEmitter {
24
+ constructor() {
25
+ super();
26
+ this.setMaxListeners(50);
27
+ }
28
+
29
+ /**
30
+ * Emit and log an event for audit trail.
31
+ */
32
+ emitEvent(event, data = {}) {
33
+ const payload = {
34
+ event,
35
+ timestamp: new Date().toISOString(),
36
+ ...data,
37
+ };
38
+ this.emit(event, payload);
39
+ this.emit("audit:event", payload);
40
+ return payload;
41
+ }
42
+ }
43
+
44
+ // Singleton
45
+ const eventBus = new AgentEventBus();
46
+ export default eventBus;
@@ -0,0 +1,67 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+
3
+ /**
4
+ * Task data model.
5
+ *
6
+ * States: pending → running → completed | failed
7
+ *
8
+ * Every incoming request (from any channel) becomes a Task.
9
+ */
10
+ export function createTask({
11
+ input,
12
+ channel = "http",
13
+ channelMeta = {},
14
+ sessionId = null,
15
+ priority = 5,
16
+ model = null,
17
+ maxCost = null,
18
+ approvalMode = "auto",
19
+ }) {
20
+ return {
21
+ id: uuidv4(),
22
+ status: "pending",
23
+ input, // user's message text
24
+ channel, // http | telegram | whatsapp | email | a2a
25
+ channelMeta, // channel-specific metadata (chat_id, phone, email, etc.)
26
+ sessionId, // link to conversation session
27
+ priority, // 1 (highest) - 10 (lowest)
28
+ model, // explicit model override or null (use default)
29
+ maxCost, // per-task cost budget or null (use global)
30
+ approvalMode, // auto | dangerous-only | every-tool | milestones
31
+ result: null, // final response text
32
+ error: null, // error message if failed
33
+ cost: {
34
+ inputTokens: 0,
35
+ outputTokens: 0,
36
+ estimatedCost: 0,
37
+ modelCalls: 0,
38
+ },
39
+ toolCalls: [], // log of tool calls: { tool, params, duration, output_preview }
40
+ createdAt: new Date().toISOString(),
41
+ startedAt: null,
42
+ completedAt: null,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Task state transitions.
48
+ */
49
+ export function startTask(task) {
50
+ task.status = "running";
51
+ task.startedAt = new Date().toISOString();
52
+ return task;
53
+ }
54
+
55
+ export function completeTask(task, result) {
56
+ task.status = "completed";
57
+ task.result = result;
58
+ task.completedAt = new Date().toISOString();
59
+ return task;
60
+ }
61
+
62
+ export function failTask(task, error) {
63
+ task.status = "failed";
64
+ task.error = error;
65
+ task.completedAt = new Date().toISOString();
66
+ return task;
67
+ }
@@ -0,0 +1,206 @@
1
+ import { createTask, startTask, completeTask, failTask } from "./Task.js";
2
+ import { saveTask, loadTask, recoverStaleTasks } from "../storage/TaskStore.js";
3
+ import eventBus from "./EventBus.js";
4
+
5
+ /**
6
+ * In-memory priority task queue with file persistence.
7
+ *
8
+ * Tasks are:
9
+ * 1. Enqueued (from any channel) → status: pending
10
+ * 2. Dequeued by TaskRunner → status: running
11
+ * 3. Completed or Failed → status: completed/failed
12
+ *
13
+ * File persistence: every state change saves to data/tasks/.
14
+ * On crash recovery: stale "running" tasks are reset to "pending".
15
+ */
16
+ class TaskQueue {
17
+ constructor() {
18
+ this.queue = []; // sorted by priority (lower number = higher priority)
19
+ this.active = new Map(); // taskId → task (currently running)
20
+ this.waiters = new Map(); // taskId → { resolve, reject } for sync HTTP callers
21
+ }
22
+
23
+ /**
24
+ * Initialize queue — recover any stale tasks from previous crash.
25
+ */
26
+ init() {
27
+ recoverStaleTasks();
28
+ console.log(`[TaskQueue] Initialized`);
29
+ }
30
+
31
+ /**
32
+ * Add a new task to the queue.
33
+ * @returns {object} The created task
34
+ */
35
+ enqueue(taskInput) {
36
+ const task = createTask(taskInput);
37
+ saveTask(task);
38
+
39
+ // Insert into queue sorted by priority
40
+ const insertIdx = this.queue.findIndex((t) => t.priority > task.priority);
41
+ if (insertIdx === -1) {
42
+ this.queue.push(task);
43
+ } else {
44
+ this.queue.splice(insertIdx, 0, task);
45
+ }
46
+
47
+ eventBus.emitEvent("task:created", { taskId: task.id, channel: task.channel, priority: task.priority });
48
+ console.log(`[TaskQueue] Enqueued task ${task.id} (priority: ${task.priority}, queue size: ${this.queue.length})`);
49
+
50
+ return task;
51
+ }
52
+
53
+ /**
54
+ * Get the next task to process.
55
+ * @param {Set<string>} skipSessions - Session IDs currently being processed (skip tasks from these sessions)
56
+ * @returns {object|null} Next task or null if queue is empty or all tasks are session-blocked
57
+ */
58
+ dequeue(skipSessions = null) {
59
+ if (this.queue.length === 0) return null;
60
+
61
+ // Find first task not belonging to an already-active session.
62
+ // This prevents two messages from the same user running concurrently,
63
+ // which would cause history corruption (last-write-wins on setMessages).
64
+ let idx = 0;
65
+ if (skipSessions && skipSessions.size > 0) {
66
+ idx = this.queue.findIndex(
67
+ (t) => !t.sessionId || !skipSessions.has(t.sessionId)
68
+ );
69
+ if (idx === -1) return null; // all pending tasks are session-blocked
70
+ }
71
+
72
+ const [task] = this.queue.splice(idx, 1);
73
+ startTask(task);
74
+ saveTask(task);
75
+ this.active.set(task.id, task);
76
+
77
+ eventBus.emitEvent("task:started", { taskId: task.id });
78
+ return task;
79
+ }
80
+
81
+ /**
82
+ * Mark a task as completed.
83
+ */
84
+ complete(taskId, result) {
85
+ const task = this.active.get(taskId);
86
+ if (!task) return;
87
+
88
+ completeTask(task, result);
89
+ saveTask(task);
90
+ this.active.delete(taskId);
91
+
92
+ eventBus.emitEvent("task:completed", { taskId: task.id, cost: task.cost });
93
+
94
+ // Resolve any sync waiters
95
+ const waiter = this.waiters.get(taskId);
96
+ if (waiter) {
97
+ waiter.resolve(task);
98
+ this.waiters.delete(taskId);
99
+ }
100
+
101
+ return task;
102
+ }
103
+
104
+ /**
105
+ * Mark a task as failed.
106
+ */
107
+ fail(taskId, error) {
108
+ const task = this.active.get(taskId);
109
+ if (!task) return;
110
+
111
+ failTask(task, error);
112
+ saveTask(task);
113
+ this.active.delete(taskId);
114
+
115
+ eventBus.emitEvent("task:failed", { taskId: task.id, error });
116
+
117
+ // Reject any sync waiters
118
+ const waiter = this.waiters.get(taskId);
119
+ if (waiter) {
120
+ waiter.resolve(task); // resolve not reject — caller handles error state
121
+ this.waiters.delete(taskId);
122
+ }
123
+
124
+ return task;
125
+ }
126
+
127
+ /**
128
+ * Wait for a task to complete (used by sync HTTP callers).
129
+ * @returns {Promise<object>} The completed task
130
+ */
131
+ waitForCompletion(taskId, timeoutMs = 300000) {
132
+ return new Promise((resolve, reject) => {
133
+ // Check if already done
134
+ const existing = loadTask(taskId);
135
+ if (existing && (existing.status === "completed" || existing.status === "failed")) {
136
+ resolve(existing);
137
+ return;
138
+ }
139
+
140
+ // Timeout guard — prevent hanging forever (default 5 min)
141
+ const timer = setTimeout(() => {
142
+ this.waiters.delete(taskId);
143
+ resolve({
144
+ id: taskId,
145
+ status: "failed",
146
+ result: "Task timed out after " + (timeoutMs / 1000) + " seconds. The task may still be running in the background.",
147
+ });
148
+ }, timeoutMs);
149
+
150
+ this.waiters.set(taskId, {
151
+ resolve: (task) => { clearTimeout(timer); resolve(task); },
152
+ reject: (err) => { clearTimeout(timer); reject(err); },
153
+ });
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Get queue stats.
159
+ */
160
+ stats() {
161
+ return {
162
+ pending: this.queue.length,
163
+ active: this.active.size,
164
+ waiters: this.waiters.size,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Peek at the next task without removing it.
170
+ * @returns {object|null}
171
+ */
172
+ peek() {
173
+ return this.queue[0] || null;
174
+ }
175
+
176
+ /**
177
+ * Silently absorb a task into the already-running session.
178
+ * Marks it completed with merged=true so channels skip sending a reply.
179
+ */
180
+ merge(taskId) {
181
+ const task = this.active.get(taskId);
182
+ if (!task) return;
183
+ completeTask(task, "");
184
+ task.merged = true;
185
+ saveTask(task);
186
+ this.active.delete(taskId);
187
+ eventBus.emitEvent("task:completed", { taskId: task.id, merged: true });
188
+ const waiter = this.waiters.get(taskId);
189
+ if (waiter) {
190
+ waiter.resolve(task);
191
+ this.waiters.delete(taskId);
192
+ }
193
+ return task;
194
+ }
195
+
196
+ /**
197
+ * Check if there are tasks to process.
198
+ */
199
+ hasWork() {
200
+ return this.queue.length > 0;
201
+ }
202
+ }
203
+
204
+ // Singleton
205
+ const taskQueue = new TaskQueue();
206
+ export default taskQueue;
@@ -0,0 +1,226 @@
1
+ import { runAgentLoop } from "./AgentLoop.js";
2
+ import { buildSystemPrompt } from "../systemPrompt.js";
3
+ import { toolFunctions } from "../tools/index.js";
4
+ import { createSession, getSession, setMessages } from "../services/sessions.js";
5
+ import taskQueue from "./TaskQueue.js";
6
+ import { isDailyBudgetExceeded, isTenantDailyBudgetExceeded } from "./CostTracker.js";
7
+ import { config } from "../config/default.js";
8
+ import tenantManager from "../tenants/TenantManager.js";
9
+ import tenantContext from "../tenants/TenantContext.js";
10
+
11
+ /**
12
+ * Task runner — worker loop that picks tasks from the queue and executes them.
13
+ *
14
+ * Configurable concurrency (default: 2 parallel tasks).
15
+ */
16
+ class TaskRunner {
17
+ constructor() {
18
+ this.running = false;
19
+ this.concurrency = 2;
20
+ this.activeCount = 0;
21
+ this.activeSessions = new Set(); // session IDs currently being processed
22
+ this.sessionSteerQueues = new Map(); // sessionId → steerQueue[] for inject-on-concurrent
23
+ this.pollInterval = null;
24
+ }
25
+
26
+ /**
27
+ * Start the runner.
28
+ */
29
+ start() {
30
+ if (this.running) return;
31
+ this.running = true;
32
+
33
+ // Poll for new tasks every 500ms
34
+ this.pollInterval = setInterval(() => this.tick(), 500);
35
+ console.log(`[TaskRunner] Started (concurrency: ${this.concurrency})`);
36
+ }
37
+
38
+ /**
39
+ * Stop the runner.
40
+ */
41
+ stop() {
42
+ this.running = false;
43
+ if (this.pollInterval) {
44
+ clearInterval(this.pollInterval);
45
+ this.pollInterval = null;
46
+ }
47
+ console.log(`[TaskRunner] Stopped`);
48
+ }
49
+
50
+ /**
51
+ * Poll tick — pick up work if available and under concurrency limit.
52
+ */
53
+ tick() {
54
+ if (!this.running) return;
55
+ if (!taskQueue.hasWork()) return;
56
+
57
+ // ── Steer/inject: if next task belongs to an already-running session,
58
+ // append its message into the live agent loop rather than spawning a
59
+ // second loop. The agent picks it up between tool calls (like Claude Code).
60
+ // This runs regardless of concurrency — no extra slot needed.
61
+ const nextTask = taskQueue.peek();
62
+ if (nextTask?.sessionId && this.sessionSteerQueues.has(nextTask.sessionId)) {
63
+ const steerTask = taskQueue.dequeue(); // consume from queue
64
+ const steerQueue = this.sessionSteerQueues.get(steerTask.sessionId);
65
+ steerQueue.push({ type: "user", content: steerTask.input }); // inject into live loop
66
+ taskQueue.merge(steerTask.id); // complete silently — no duplicate reply
67
+ console.log(`[TaskRunner] Follow-up "${steerTask.input.slice(0, 60)}" injected into running session ${steerTask.sessionId}`);
68
+ return;
69
+ }
70
+
71
+ // For starting a fresh agent loop, check concurrency + budget limits
72
+ if (this.activeCount >= this.concurrency) return;
73
+
74
+ if (isDailyBudgetExceeded()) {
75
+ console.log(`[TaskRunner] Daily budget exceeded ($${config.maxDailyCost}). Pausing.`);
76
+ return;
77
+ }
78
+
79
+ // Normal path: start a fresh agent loop for this task
80
+ const task = taskQueue.dequeue(this.activeSessions);
81
+ if (!task) return;
82
+
83
+ // Set up steerQueue synchronously before processTask (no async gap → no race)
84
+ const steerQueue = [];
85
+ if (task.sessionId) {
86
+ this.activeSessions.add(task.sessionId);
87
+ this.sessionSteerQueues.set(task.sessionId, steerQueue);
88
+ }
89
+
90
+ this.activeCount++;
91
+ this.processTask(task, steerQueue).finally(() => {
92
+ this.activeCount--;
93
+ if (task.sessionId) {
94
+ this.activeSessions.delete(task.sessionId);
95
+ this.sessionSteerQueues.delete(task.sessionId);
96
+ }
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Process a single task.
102
+ * @param {object} task
103
+ * @param {Array} steerQueue - Shared array; follow-up messages from the same session are
104
+ * pushed here and picked up by AgentLoop between tool calls.
105
+ */
106
+ async processTask(task, steerQueue = []) {
107
+ console.log(`\n[TaskRunner] Processing task ${task.id} from ${task.channel}`);
108
+
109
+ // ── Multi-tenant: resolve tenant and effective config ──────────────────────
110
+ // Derive userId from sessionId: sessionId = "${channel}-${userId}"
111
+ let tenant = null;
112
+ if (task.channel && task.sessionId) {
113
+ const userId = task.sessionId.slice(task.channel.length + 1);
114
+ if (userId) {
115
+ tenant = tenantManager.getOrCreate(task.channel, userId);
116
+ }
117
+ }
118
+
119
+ // Block suspended tenants immediately
120
+ if (tenant?.suspended) {
121
+ const reason = tenant.suspendReason
122
+ ? `Account suspended: ${tenant.suspendReason}`
123
+ : "Account suspended. Contact the operator.";
124
+ console.log(`[TaskRunner] Task ${task.id} rejected — tenant ${tenant.id} is suspended`);
125
+ taskQueue.fail(task.id, reason);
126
+ return;
127
+ }
128
+
129
+ // Resolve effective config: tenant config > channel config > global config
130
+ const resolvedConfig = tenantManager.resolveTaskConfig(tenant, task.channelModel || null);
131
+
132
+ // Per-tenant daily budget check (separate from global budget)
133
+ if (tenant && resolvedConfig.maxDailyCost) {
134
+ if (isTenantDailyBudgetExceeded(tenant.id, resolvedConfig.maxDailyCost)) {
135
+ console.log(`[TaskRunner] Task ${task.id} rejected — tenant ${tenant.id} daily budget ($${resolvedConfig.maxDailyCost}) reached`);
136
+ taskQueue.fail(task.id, `Daily budget of $${resolvedConfig.maxDailyCost} reached. Tasks resume tomorrow.`);
137
+ return;
138
+ }
139
+ }
140
+
141
+ // Narrow tool list if tenant has a tool allowlist
142
+ let tools = resolvedConfig.tools?.length
143
+ ? Object.fromEntries(Object.entries(toolFunctions).filter(([k]) => resolvedConfig.tools.includes(k)))
144
+ : { ...toolFunctions };
145
+
146
+ // Filter MCP tools by per-tenant mcpServers allowlist (null = all allowed)
147
+ const allowedMcpServers = resolvedConfig.mcpServers; // null = all
148
+ if (allowedMcpServers !== null) {
149
+ tools = Object.fromEntries(
150
+ Object.entries(tools).filter(([name]) => {
151
+ if (!name.startsWith("mcp__")) return true;
152
+ const serverName = name.split("__")[1];
153
+ return allowedMcpServers.includes(serverName);
154
+ })
155
+ );
156
+ }
157
+
158
+ // Resolved model for this task (used by sub-agents to inherit parent model)
159
+ const resolvedModel = resolvedConfig.model || task.model || config.defaultModel;
160
+ const apiKeys = resolvedConfig.apiKeys || {};
161
+
162
+ try {
163
+ // Wrap entire task execution in tenant context (AsyncLocalStorage).
164
+ // This allows FilesystemGuard, memory tools, and other tools to read per-tenant config
165
+ // without any race conditions across concurrent tasks.
166
+ await tenantContext.run({ tenant, resolvedConfig, resolvedModel, apiKeys }, async () => {
167
+ // Get or create session
168
+ let session = task.sessionId ? getSession(task.sessionId) : null;
169
+ if (!session) {
170
+ session = createSession(task.sessionId || null);
171
+ task.sessionId = session.sessionId;
172
+ }
173
+
174
+ // Build system prompt (SOUL.md + MEMORY.md + semantic recall + daily log + matched skills)
175
+ const systemPrompt = await buildSystemPrompt(task.input);
176
+
177
+ // Build message history
178
+ const previousMessages = session.messages.map((m) => ({
179
+ role: m.role,
180
+ content: m.content,
181
+ }));
182
+ const messages = [...previousMessages, { role: "user", content: task.input }];
183
+
184
+ // Run agent loop with resolved model, cost limits, and per-tenant API keys.
185
+ // steerQueue lets follow-up messages from the same user be injected live
186
+ // between tool calls instead of spawning a competing agent loop.
187
+ const result = await runAgentLoop({
188
+ messages,
189
+ systemPrompt,
190
+ tools,
191
+ modelId: resolvedModel,
192
+ taskId: task.id,
193
+ approvalMode: task.approvalMode || "auto",
194
+ channelMeta: task.channelMeta || null,
195
+ maxCostPerTask: resolvedConfig.maxCostPerTask,
196
+ apiKeys,
197
+ steerQueue,
198
+ });
199
+
200
+ // Update session with conversation
201
+ setMessages(session.sessionId, result.messages);
202
+
203
+ // Update task cost info
204
+ task.cost = result.cost;
205
+
206
+ // Record cost against tenant lifetime totals
207
+ if (tenant) {
208
+ tenantManager.recordCost(tenant.id, result.cost || 0);
209
+ }
210
+
211
+ // Complete the task
212
+ taskQueue.complete(task.id, result.text);
213
+ const costStr = result.cost ? ` cost: $${result.cost.toFixed(4)}` : "";
214
+ const tenantStr = tenant ? ` tenant: ${tenant.id}` : "";
215
+ console.log(`[TaskRunner] Task ${task.id} completed (${costStr}${tenantStr})`);
216
+ });
217
+ } catch (error) {
218
+ console.error(`[TaskRunner] Task ${task.id} failed:`, error.message);
219
+ taskQueue.fail(task.id, error.message);
220
+ }
221
+ }
222
+ }
223
+
224
+ // Singleton
225
+ const taskRunner = new TaskRunner();
226
+ export default taskRunner;