@treenity/mods 3.0.2 → 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/client.ts +2 -0
- package/agent/guardian.ts +492 -0
- package/agent/seed.ts +74 -0
- package/agent/server.ts +4 -0
- package/agent/service.ts +644 -0
- package/agent/types.ts +184 -0
- package/agent/view.tsx +431 -0
- package/dist/agent/client.d.ts +3 -0
- package/dist/agent/client.d.ts.map +1 -0
- package/dist/agent/client.js +3 -0
- package/dist/agent/client.js.map +1 -0
- package/dist/agent/guardian.d.ts +47 -0
- package/dist/agent/guardian.d.ts.map +1 -0
- package/dist/agent/guardian.js +452 -0
- package/dist/agent/guardian.js.map +1 -0
- package/dist/agent/seed.d.ts +2 -0
- package/dist/agent/seed.d.ts.map +1 -0
- package/dist/agent/seed.js +68 -0
- package/dist/agent/seed.js.map +1 -0
- package/dist/agent/server.d.ts +5 -0
- package/dist/agent/server.d.ts.map +1 -0
- package/dist/agent/server.js +5 -0
- package/dist/agent/server.js.map +1 -0
- package/dist/agent/service.d.ts +2 -0
- package/dist/agent/service.d.ts.map +1 -0
- package/dist/agent/service.js +556 -0
- package/dist/agent/service.js.map +1 -0
- package/dist/agent/types.d.ts +115 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +168 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/agent/view.d.ts +2 -0
- package/dist/agent/view.d.ts.map +1 -0
- package/dist/agent/view.js +137 -0
- package/dist/agent/view.js.map +1 -0
- package/dist/mcp/mcp-server.d.ts +16 -0
- package/dist/mcp/mcp-server.d.ts.map +1 -0
- package/dist/mcp/mcp-server.js +344 -0
- package/dist/mcp/mcp-server.js.map +1 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +3 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/service.d.ts +2 -0
- package/dist/mcp/service.d.ts.map +1 -0
- package/dist/mcp/service.js +16 -0
- package/dist/mcp/service.js.map +1 -0
- package/dist/mcp/types.d.ts +4 -0
- package/dist/mcp/types.d.ts.map +1 -0
- package/dist/mcp/types.js +6 -0
- package/dist/mcp/types.js.map +1 -0
- package/dist/metatron/claude.d.ts +30 -0
- package/dist/metatron/claude.d.ts.map +1 -0
- package/dist/metatron/claude.js +201 -0
- package/dist/metatron/claude.js.map +1 -0
- package/dist/metatron/client.d.ts +3 -0
- package/dist/metatron/client.d.ts.map +1 -0
- package/dist/metatron/client.js +3 -0
- package/dist/metatron/client.js.map +1 -0
- package/dist/metatron/mentions.d.ts +9 -0
- package/dist/metatron/mentions.d.ts.map +1 -0
- package/dist/metatron/mentions.js +21 -0
- package/dist/metatron/mentions.js.map +1 -0
- package/dist/metatron/permissions.d.ts +16 -0
- package/dist/metatron/permissions.d.ts.map +1 -0
- package/dist/metatron/permissions.js +52 -0
- package/dist/metatron/permissions.js.map +1 -0
- package/dist/metatron/seed.d.ts +2 -0
- package/dist/metatron/seed.d.ts.map +1 -0
- package/dist/metatron/seed.js +41 -0
- package/dist/metatron/seed.js.map +1 -0
- package/dist/metatron/server.d.ts +4 -0
- package/dist/metatron/server.d.ts.map +1 -0
- package/dist/metatron/server.js +4 -0
- package/dist/metatron/server.js.map +1 -0
- package/dist/metatron/service.d.ts +2 -0
- package/dist/metatron/service.d.ts.map +1 -0
- package/dist/metatron/service.js +361 -0
- package/dist/metatron/service.js.map +1 -0
- package/dist/metatron/types.d.ts +76 -0
- package/dist/metatron/types.d.ts.map +1 -0
- package/dist/metatron/types.js +112 -0
- package/dist/metatron/types.js.map +1 -0
- package/dist/metatron/view.d.ts +4 -0
- package/dist/metatron/view.d.ts.map +1 -0
- package/dist/metatron/view.js +5 -0
- package/dist/metatron/view.js.map +1 -0
- package/dist/metatron/views/config.d.ts +2 -0
- package/dist/metatron/views/config.d.ts.map +1 -0
- package/dist/metatron/views/config.js +116 -0
- package/dist/metatron/views/config.js.map +1 -0
- package/dist/metatron/views/log.d.ts +18 -0
- package/dist/metatron/views/log.d.ts.map +1 -0
- package/dist/metatron/views/log.js +224 -0
- package/dist/metatron/views/log.js.map +1 -0
- package/dist/metatron/views/shared.d.ts +13 -0
- package/dist/metatron/views/shared.d.ts.map +1 -0
- package/dist/metatron/views/shared.js +33 -0
- package/dist/metatron/views/shared.js.map +1 -0
- package/dist/metatron/views/task.d.ts +4 -0
- package/dist/metatron/views/task.d.ts.map +1 -0
- package/dist/metatron/views/task.js +106 -0
- package/dist/metatron/views/task.js.map +1 -0
- package/dist/metatron/views/workspace.d.ts +2 -0
- package/dist/metatron/views/workspace.d.ts.map +1 -0
- package/dist/metatron/views/workspace.js +138 -0
- package/dist/metatron/views/workspace.js.map +1 -0
- package/mcp/mcp-server.ts +393 -0
- package/mcp/server.ts +2 -0
- package/mcp/service.ts +18 -0
- package/mcp/types.ts +6 -0
- package/metatron/CLAUDE.md +22 -0
- package/metatron/claude.ts +258 -0
- package/metatron/client.ts +2 -0
- package/metatron/mentions.ts +31 -0
- package/metatron/permissions.ts +76 -0
- package/metatron/seed.ts +50 -0
- package/metatron/server.ts +3 -0
- package/metatron/service.ts +406 -0
- package/metatron/types.ts +120 -0
- package/metatron/view.tsx +4 -0
- package/metatron/views/config.tsx +408 -0
- package/metatron/views/log.tsx +412 -0
- package/metatron/views/shared.tsx +40 -0
- package/metatron/views/task.tsx +255 -0
- package/metatron/views/workspace.tsx +418 -0
- package/package.json +6 -2
- package/dist/mindmap/radial-tree.d.ts +0 -14
- package/dist/mindmap/radial-tree.d.ts.map +0 -1
- package/dist/mindmap/radial-tree.js +0 -184
- package/dist/mindmap/radial-tree.js.map +0 -1
- package/dist/mindmap/use-tree-data.d.ts +0 -14
- package/dist/mindmap/use-tree-data.d.ts.map +0 -1
- package/dist/mindmap/use-tree-data.js +0 -95
- package/dist/mindmap/use-tree-data.js.map +0 -1
package/agent/service.ts
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// Agent Office — orchestrator service on /agents node.
|
|
2
|
+
// Two modes: DISCUSS (lightweight multi-agent chat) and WORK (full agent run).
|
|
3
|
+
// Watches /board/data for tasks. Manages concurrency pool.
|
|
4
|
+
// Deterministic routing — no LLM tokens burned on orchestration.
|
|
5
|
+
|
|
6
|
+
import { invokeClaude } from '#metatron/claude';
|
|
7
|
+
import { MetatronConfig } from '#metatron/types';
|
|
8
|
+
import { type Class, type ComponentData, createNode, getComponent, type NodeData, register } from '@treenity/core';
|
|
9
|
+
import { setComponent } from '@treenity/core/comp';
|
|
10
|
+
import type { ServiceCtx } from '@treenity/core/contexts/service';
|
|
11
|
+
import { createLogger } from '@treenity/core/log';
|
|
12
|
+
import type { ActionCtx } from '@treenity/core/server/actions';
|
|
13
|
+
import { debouncedWrite } from '@treenity/core/util/debounced-write';
|
|
14
|
+
import { buildPermissionRules, createCanUseTool, reconcileOnStartup } from './guardian';
|
|
15
|
+
import { AiAgent, AiAssignment, AiPlan, AiPool, AiThread, type ThreadMessage } from './types';
|
|
16
|
+
|
|
17
|
+
const log = createLogger('agent-office');
|
|
18
|
+
|
|
19
|
+
const MAX_OCC_RETRIES = 3;
|
|
20
|
+
|
|
21
|
+
/** Re-read + mutate + write with OCC retry. Returns fresh node. */
|
|
22
|
+
async function updateNode(
|
|
23
|
+
store: ServiceCtx['tree'],
|
|
24
|
+
path: string,
|
|
25
|
+
mutate: (node: NodeData) => void,
|
|
26
|
+
): Promise<NodeData | null> {
|
|
27
|
+
for (let attempt = 0; attempt < MAX_OCC_RETRIES; attempt++) {
|
|
28
|
+
const node = await store.get(path);
|
|
29
|
+
if (!node) return null;
|
|
30
|
+
mutate(node);
|
|
31
|
+
try {
|
|
32
|
+
await store.set(node);
|
|
33
|
+
return node;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof Error && err.message.startsWith('OptimisticConcurrencyError') && attempt < MAX_OCC_RETRIES - 1) {
|
|
36
|
+
log.warn(`OCC conflict on ${path}, retry ${attempt + 1}`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Update a specific component on a node with OCC retry. Mutates in-place (getComponent returns a ref). */
|
|
46
|
+
async function updateComp<T>(
|
|
47
|
+
store: ServiceCtx['tree'],
|
|
48
|
+
path: string,
|
|
49
|
+
Type: Class<T>,
|
|
50
|
+
mutate: (comp: ComponentData<T>) => void,
|
|
51
|
+
): Promise<NodeData | null> {
|
|
52
|
+
return updateNode(store, path, (node) => {
|
|
53
|
+
const comp = getComponent(node, Type);
|
|
54
|
+
if (!comp) return;
|
|
55
|
+
mutate(comp);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Prompt builders ──
|
|
60
|
+
|
|
61
|
+
function threadSince(task: NodeData, cursor: number): string {
|
|
62
|
+
const thread = getComponent(task, AiThread);
|
|
63
|
+
if (!thread?.messages?.length) return '';
|
|
64
|
+
const msgs = thread.messages.slice(cursor);
|
|
65
|
+
if (!msgs.length) return '';
|
|
66
|
+
return '\n## Discussion\n' + msgs.map(m =>
|
|
67
|
+
`**${m.role}** (${m.from}): ${m.text}`
|
|
68
|
+
).join('\n') + '\n';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildWorkPrompt(role: string, task: NodeData, agent: NodeData, cursor: number): string {
|
|
72
|
+
const title = task.title || '(untitled)';
|
|
73
|
+
const desc = task.description || '';
|
|
74
|
+
|
|
75
|
+
const config = getComponent(agent, MetatronConfig);
|
|
76
|
+
if (!config) throw new Error(`agent ${agent.$path} missing metatron.config component`);
|
|
77
|
+
|
|
78
|
+
const base = config.systemPrompt
|
|
79
|
+
? String(config.systemPrompt)
|
|
80
|
+
: `You are a ${role} agent for the Treenity project.`;
|
|
81
|
+
|
|
82
|
+
const plan = getComponent(task, AiPlan);
|
|
83
|
+
const planSection = plan?.text && plan.approved
|
|
84
|
+
? `\n## Approved Plan\n${plan.text}\n${plan.feedback ? `\n### Human feedback\n${plan.feedback}\n` : ''}`
|
|
85
|
+
: '';
|
|
86
|
+
|
|
87
|
+
return `${base}
|
|
88
|
+
|
|
89
|
+
## Current Task
|
|
90
|
+
**${title}**
|
|
91
|
+
${desc}
|
|
92
|
+
${planSection}${threadSince(task, cursor)}
|
|
93
|
+
## Instructions
|
|
94
|
+
- Follow the approved plan above${plan?.feedback ? ' — incorporate the feedback' : ''}
|
|
95
|
+
- Use MCP tools to inspect the codebase and tree
|
|
96
|
+
- Complete the task according to your role
|
|
97
|
+
- Be concise in your response
|
|
98
|
+
- Report what you did and the result`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildDiscussPrompt(role: string, task: NodeData, cursor: number): string {
|
|
102
|
+
const title = task.title || '(untitled)';
|
|
103
|
+
const desc = task.description || '';
|
|
104
|
+
|
|
105
|
+
return `You are a ${role} agent. You've been asked to join a discussion on a task.
|
|
106
|
+
|
|
107
|
+
## Task
|
|
108
|
+
**${title}**
|
|
109
|
+
${desc}
|
|
110
|
+
${threadSince(task, cursor)}
|
|
111
|
+
## Instructions
|
|
112
|
+
- Read the discussion above
|
|
113
|
+
- Share your perspective as a ${role}
|
|
114
|
+
- Be concise — one short paragraph
|
|
115
|
+
- Do NOT start working on the task — discussion only`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildPlanPrompt(role: string, task: NodeData, agent: NodeData, cursor: number): string {
|
|
119
|
+
const title = task.title || '(untitled)';
|
|
120
|
+
const desc = task.description || '';
|
|
121
|
+
|
|
122
|
+
const config = getComponent(agent, MetatronConfig);
|
|
123
|
+
if (!config) throw new Error(`agent ${agent.$path} missing metatron.config component`);
|
|
124
|
+
|
|
125
|
+
const base = config.systemPrompt
|
|
126
|
+
? String(config.systemPrompt)
|
|
127
|
+
: `You are a ${role} agent for the Treenity project.`;
|
|
128
|
+
|
|
129
|
+
const plan = getComponent(task, AiPlan);
|
|
130
|
+
const prevPlanSection = plan?.text && plan.feedback
|
|
131
|
+
? `\n## Your previous plan (REJECTED)\n${plan.text}\n`
|
|
132
|
+
: '';
|
|
133
|
+
const feedbackSection = plan?.feedback
|
|
134
|
+
? `\n## Feedback on previous plan\n${plan.feedback}\n`
|
|
135
|
+
: '';
|
|
136
|
+
|
|
137
|
+
return `${base}
|
|
138
|
+
|
|
139
|
+
## Task — PLAN ONLY
|
|
140
|
+
**${title}**
|
|
141
|
+
${desc}
|
|
142
|
+
${prevPlanSection}${feedbackSection}${threadSince(task, cursor)}
|
|
143
|
+
## Instructions — PLANNING MODE
|
|
144
|
+
You must write a detailed execution plan. Do NOT execute anything yet.
|
|
145
|
+
|
|
146
|
+
1. **Analyze** the task — use MCP tools to read current state, understand context
|
|
147
|
+
2. **Write a plan** — numbered steps, specific files/nodes to change, expected outcomes
|
|
148
|
+
3. **Identify risks** — what could go wrong, what needs clarification
|
|
149
|
+
4. **Estimate scope** — small (< 5 changes), medium, large
|
|
150
|
+
|
|
151
|
+
Output your plan as structured markdown. The human will review, comment, and approve before you execute.
|
|
152
|
+
|
|
153
|
+
**DO NOT make any changes. Planning only.**`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Pool management ──
|
|
157
|
+
|
|
158
|
+
function poolAcquire(pool: AiPool, agentPath: string): boolean {
|
|
159
|
+
// Guard against duplicate active entries (race: concurrent processInbox calls)
|
|
160
|
+
if (pool.active.includes(agentPath)) return false;
|
|
161
|
+
|
|
162
|
+
if (pool.active.length >= pool.maxConcurrent) {
|
|
163
|
+
if (!pool.queue.includes(agentPath)) pool.queue.push(agentPath);
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
pool.active.push(agentPath);
|
|
167
|
+
pool.queue = pool.queue.filter(p => p !== agentPath);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function poolRelease(pool: AiPool, agentPath: string): string | null {
|
|
172
|
+
pool.active = pool.active.filter(p => p !== agentPath);
|
|
173
|
+
return pool.queue.shift() ?? null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Discussion runner (lightweight) ──
|
|
177
|
+
|
|
178
|
+
async function discussAgent(
|
|
179
|
+
agentNode: NodeData,
|
|
180
|
+
taskNode: NodeData,
|
|
181
|
+
store: ServiceCtx['tree'],
|
|
182
|
+
poolPath: string,
|
|
183
|
+
) {
|
|
184
|
+
const agent = getComponent(agentNode, AiAgent);
|
|
185
|
+
if (!agent) throw new Error(`not an ai.agent: ${agentNode.$path}`);
|
|
186
|
+
|
|
187
|
+
const role = agent.role;
|
|
188
|
+
const assignment = getComponent(taskNode, AiAssignment);
|
|
189
|
+
const cursor = assignment?.cursors?.[agentNode.$path] ?? 0;
|
|
190
|
+
const prompt = buildDiscussPrompt(role, taskNode, cursor);
|
|
191
|
+
|
|
192
|
+
log.info(`discuss: ${role} on ${taskNode.$path}`);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const result = await invokeClaude(prompt, {
|
|
196
|
+
key: `discuss:${agentNode.$path}:${taskNode.$path}`,
|
|
197
|
+
model: 'claude-haiku-4-5-20251001',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const text = (result.text || '').trim();
|
|
201
|
+
if (!text) return;
|
|
202
|
+
|
|
203
|
+
// Post to thread
|
|
204
|
+
await updateNode(store, taskNode.$path, (freshTask) => {
|
|
205
|
+
const thread = getComponent(freshTask, AiThread) ?? { $type: 'ai.thread' as const, messages: [] as ThreadMessage[] };
|
|
206
|
+
thread.messages.push({ role, from: agentNode.$path, text, ts: Date.now() });
|
|
207
|
+
setComponent(freshTask, AiThread, thread);
|
|
208
|
+
|
|
209
|
+
const asgn = getComponent(freshTask, AiAssignment);
|
|
210
|
+
if (asgn) {
|
|
211
|
+
asgn.cursors = { ...asgn.cursors, [agentNode.$path]: thread.messages.length };
|
|
212
|
+
asgn.nextRoles = asgn.nextRoles.filter(r => r !== role);
|
|
213
|
+
setComponent(freshTask, AiAssignment, asgn);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const remainingRoles = asgn?.nextRoles?.length ?? 0;
|
|
217
|
+
if (remainingRoles === 0) freshTask.aiStatus = '';
|
|
218
|
+
});
|
|
219
|
+
log.info(`discuss: ${role} posted to ${taskNode.$path} — cost=$${result.costUsd ?? '?'}`);
|
|
220
|
+
|
|
221
|
+
} catch (err) {
|
|
222
|
+
log.error(`discuss FAILED: ${role} on ${taskNode.$path}:`, err);
|
|
223
|
+
} finally {
|
|
224
|
+
await updateComp(store, agentNode.$path, AiAgent, (c) => {
|
|
225
|
+
c.status = 'idle';
|
|
226
|
+
c.currentTask = '';
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await updateComp(store, poolPath, AiPool, (c) => {
|
|
230
|
+
poolRelease(c, agentNode.$path);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Work runner (full agent → metatron.task with live streaming) ──
|
|
236
|
+
|
|
237
|
+
async function runAgent(
|
|
238
|
+
agentNode: NodeData,
|
|
239
|
+
taskNode: NodeData,
|
|
240
|
+
store: ServiceCtx['tree'],
|
|
241
|
+
poolPath: string,
|
|
242
|
+
) {
|
|
243
|
+
const agent = getComponent(agentNode, AiAgent);
|
|
244
|
+
if (!agent) throw new Error(`not an ai.agent: ${agentNode.$path}`);
|
|
245
|
+
|
|
246
|
+
const config = getComponent(agentNode, MetatronConfig);
|
|
247
|
+
if (!config) throw new Error(`agent ${agentNode.$path} missing metatron.config component`);
|
|
248
|
+
|
|
249
|
+
const role = agent.role;
|
|
250
|
+
const assignment = getComponent(taskNode, AiAssignment);
|
|
251
|
+
const cursor = assignment?.cursors?.[agentNode.$path] ?? 0;
|
|
252
|
+
const prompt = buildWorkPrompt(role, taskNode, agentNode, cursor);
|
|
253
|
+
const permissionRules = buildPermissionRules(role);
|
|
254
|
+
const canUseTool = createCanUseTool(role, agentNode.$path, store);
|
|
255
|
+
|
|
256
|
+
// Create metatron.task for live streaming + structured log (D29)
|
|
257
|
+
const taskId = `t-${Date.now()}`;
|
|
258
|
+
const mtTaskPath = `${agentNode.$path}/tasks/${taskId}`;
|
|
259
|
+
|
|
260
|
+
await store.set(createNode(mtTaskPath, 'metatron.task', {
|
|
261
|
+
prompt,
|
|
262
|
+
status: 'running',
|
|
263
|
+
createdAt: Date.now(),
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
// Save taskRef on agent + board task for UI linkage
|
|
267
|
+
await updateComp(store, agentNode.$path, AiAgent, (c) => {
|
|
268
|
+
c.taskRef = mtTaskPath;
|
|
269
|
+
});
|
|
270
|
+
await updateNode(store, taskNode.$path, (n) => {
|
|
271
|
+
n.taskRef = mtTaskPath;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
log.info(`work: ${role} agent ${agentNode.$path} on task ${taskNode.$path} → ${mtTaskPath}`);
|
|
275
|
+
|
|
276
|
+
// Streaming progress — debounced writes to metatron.task.log every 2s
|
|
277
|
+
let tailBuf = '';
|
|
278
|
+
const progress = debouncedWrite(async () => {
|
|
279
|
+
const t = await store.get(mtTaskPath);
|
|
280
|
+
if (t && t.status === 'running') {
|
|
281
|
+
const { $rev: _, ...rest } = t;
|
|
282
|
+
await store.set({ ...rest, log: tailBuf });
|
|
283
|
+
}
|
|
284
|
+
}, 2000, 'agent.progress');
|
|
285
|
+
|
|
286
|
+
const onOutput = (chunk: string) => {
|
|
287
|
+
tailBuf += chunk;
|
|
288
|
+
progress.trigger();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const result = await invokeClaude(prompt, {
|
|
293
|
+
key: agentNode.$path,
|
|
294
|
+
sessionId: config.sessionId || undefined,
|
|
295
|
+
model: config.model || undefined,
|
|
296
|
+
permissionRules,
|
|
297
|
+
canUseTool,
|
|
298
|
+
onOutput,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
progress.cancel();
|
|
302
|
+
|
|
303
|
+
// Finalize metatron.task
|
|
304
|
+
const finalStatus = result.aborted ? 'done' : result.error ? 'error' : 'done';
|
|
305
|
+
const mtTask = await store.get(mtTaskPath);
|
|
306
|
+
if (mtTask) {
|
|
307
|
+
await store.set({
|
|
308
|
+
...mtTask,
|
|
309
|
+
status: finalStatus,
|
|
310
|
+
log: result.output,
|
|
311
|
+
result: result.aborted
|
|
312
|
+
? (result.text || '[interrupted]')
|
|
313
|
+
: (result.text || result.output),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Save sessionId to metatron.config on agent node
|
|
318
|
+
await updateNode(store, agentNode.$path, (n) => {
|
|
319
|
+
const cfg = getComponent(n, MetatronConfig);
|
|
320
|
+
if (cfg) cfg.sessionId = result.sessionId ?? '';
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Update agent: complete
|
|
324
|
+
await updateComp(store, agentNode.$path, AiAgent, (c) => {
|
|
325
|
+
c.status = 'idle';
|
|
326
|
+
c.currentTask = '';
|
|
327
|
+
c.taskRef = '';
|
|
328
|
+
c.lastRunAt = Date.now();
|
|
329
|
+
c.totalTokens = (c.totalTokens || 0) + (result.costUsd ? Math.round(result.costUsd * 100000) : 0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Post result to board task thread + update status
|
|
333
|
+
const text = result.text || result.output || '(no output)';
|
|
334
|
+
await updateNode(store, taskNode.$path, (freshTask) => {
|
|
335
|
+
const thread = getComponent(freshTask, AiThread) ?? { $type: 'ai.thread' as const, messages: [] as ThreadMessage[] };
|
|
336
|
+
thread.messages.push({ role, from: agentNode.$path, text, ts: Date.now() });
|
|
337
|
+
setComponent(freshTask, AiThread, thread);
|
|
338
|
+
|
|
339
|
+
const asgn = getComponent(freshTask, AiAssignment);
|
|
340
|
+
if (asgn) {
|
|
341
|
+
asgn.cursors = { ...asgn.cursors, [agentNode.$path]: thread.messages.length };
|
|
342
|
+
setComponent(freshTask, AiAssignment, asgn);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
freshTask.status = 'review';
|
|
346
|
+
freshTask.aiStatus = '✅ done';
|
|
347
|
+
freshTask.result = text;
|
|
348
|
+
freshTask.updatedAt = Date.now();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
log.info(`work: ${role} done on ${taskNode.$path} — cost=$${result.costUsd ?? '?'}`);
|
|
352
|
+
|
|
353
|
+
} catch (err) {
|
|
354
|
+
const stack = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
355
|
+
log.error(`work: ${role} FAILED on ${taskNode.$path}:`, err);
|
|
356
|
+
|
|
357
|
+
progress.cancel();
|
|
358
|
+
|
|
359
|
+
// Mark metatron.task as error
|
|
360
|
+
const mtTask = await store.get(mtTaskPath);
|
|
361
|
+
if (mtTask) {
|
|
362
|
+
await store.set({ ...mtTask, status: 'error', log: tailBuf, result: `Error: ${stack}` });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await updateComp(store, agentNode.$path, AiAgent, (c) => {
|
|
366
|
+
c.status = 'error';
|
|
367
|
+
c.currentTask = '';
|
|
368
|
+
c.taskRef = '';
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await updateNode(store, taskNode.$path, (n) => {
|
|
372
|
+
n.status = 'todo';
|
|
373
|
+
n.aiStatus = '❌ error';
|
|
374
|
+
n.result = `Agent error: ${stack}`;
|
|
375
|
+
n.updatedAt = Date.now();
|
|
376
|
+
});
|
|
377
|
+
} finally {
|
|
378
|
+
await updateComp(store, poolPath, AiPool, (c) => {
|
|
379
|
+
poolRelease(c, agentNode.$path);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Plan runner (lightweight — write plan only, no execution) ──
|
|
385
|
+
|
|
386
|
+
async function planAgent(
|
|
387
|
+
agentNode: NodeData,
|
|
388
|
+
taskNode: NodeData,
|
|
389
|
+
store: ServiceCtx['tree'],
|
|
390
|
+
poolPath: string,
|
|
391
|
+
) {
|
|
392
|
+
const agent = getComponent(agentNode, AiAgent);
|
|
393
|
+
if (!agent) throw new Error(`not an ai.agent: ${agentNode.$path}`);
|
|
394
|
+
|
|
395
|
+
const config = getComponent(agentNode, MetatronConfig);
|
|
396
|
+
if (!config) throw new Error(`agent ${agentNode.$path} missing metatron.config component`);
|
|
397
|
+
|
|
398
|
+
const role = agent.role;
|
|
399
|
+
const assignment = getComponent(taskNode, AiAssignment);
|
|
400
|
+
const cursor = assignment?.cursors?.[agentNode.$path] ?? 0;
|
|
401
|
+
const prompt = buildPlanPrompt(role, taskNode, agentNode, cursor);
|
|
402
|
+
|
|
403
|
+
// Plan mode uses read-only tools only — no writes, no execution
|
|
404
|
+
const canUseTool = createCanUseTool(role, agentNode.$path, store);
|
|
405
|
+
|
|
406
|
+
log.info(`plan: ${role} agent ${agentNode.$path} planning for ${taskNode.$path}`);
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const result = await invokeClaude(prompt, {
|
|
410
|
+
key: `plan:${agentNode.$path}`,
|
|
411
|
+
sessionId: config.sessionId || undefined,
|
|
412
|
+
model: config.model || undefined,
|
|
413
|
+
canUseTool,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const planText = (result.text || result.output || '').trim();
|
|
417
|
+
|
|
418
|
+
// Save sessionId
|
|
419
|
+
await updateNode(store, agentNode.$path, (n) => {
|
|
420
|
+
const cfg = getComponent(n, MetatronConfig);
|
|
421
|
+
if (cfg) cfg.sessionId = result.sessionId ?? '';
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Save plan as ai.plan component on the task
|
|
425
|
+
await updateNode(store, taskNode.$path, (freshTask) => {
|
|
426
|
+
const plan = getComponent(freshTask, AiPlan) ?? new AiPlan();
|
|
427
|
+
plan.text = planText;
|
|
428
|
+
plan.approved = false;
|
|
429
|
+
plan.feedback = '';
|
|
430
|
+
plan.createdAt = Date.now();
|
|
431
|
+
setComponent(freshTask, AiPlan, plan);
|
|
432
|
+
|
|
433
|
+
freshTask.aiStatus = '📋 plan ready';
|
|
434
|
+
freshTask.updatedAt = Date.now();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
log.info(`plan: ${role} plan ready for ${taskNode.$path} — cost=$${result.costUsd ?? '?'}`);
|
|
438
|
+
|
|
439
|
+
} catch (err) {
|
|
440
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
441
|
+
log.error(`plan: ${role} FAILED on ${taskNode.$path}:`, err);
|
|
442
|
+
|
|
443
|
+
await updateNode(store, taskNode.$path, (n) => {
|
|
444
|
+
n.aiStatus = '❌ plan failed';
|
|
445
|
+
n.result = `Plan error: ${msg}`;
|
|
446
|
+
n.updatedAt = Date.now();
|
|
447
|
+
});
|
|
448
|
+
} finally {
|
|
449
|
+
await updateComp(store, agentNode.$path, AiAgent, (c) => {
|
|
450
|
+
c.status = 'idle';
|
|
451
|
+
c.currentTask = '';
|
|
452
|
+
c.taskRef = '';
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await updateComp(store, poolPath, AiPool, (c) => {
|
|
456
|
+
poolRelease(c, agentNode.$path);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── Actions on /agents node ──
|
|
462
|
+
|
|
463
|
+
/** @description Manually trigger inbox scan */
|
|
464
|
+
register('ai.pool', 'action:scan', async (_ctx: ActionCtx) => {
|
|
465
|
+
log.info('manual scan triggered');
|
|
466
|
+
}, { description: 'Trigger inbox scan for pending tasks' });
|
|
467
|
+
|
|
468
|
+
// ── Orchestrator service ──
|
|
469
|
+
|
|
470
|
+
register('ai.pool', 'service', async (node: NodeData, ctx: ServiceCtx) => {
|
|
471
|
+
let stopped = false;
|
|
472
|
+
const poolPath = node.$path;
|
|
473
|
+
|
|
474
|
+
log.info(`orchestrator started at ${poolPath}`);
|
|
475
|
+
|
|
476
|
+
// Clean up orphaned approvals, stuck agents/tasks from previous run
|
|
477
|
+
// Returns agents that can be resumed (have sessionId + currentTask)
|
|
478
|
+
const resumable = await reconcileOnStartup(ctx.tree);
|
|
479
|
+
|
|
480
|
+
async function getAgents(): Promise<NodeData[]> {
|
|
481
|
+
const { items } = await ctx.tree.getChildren(poolPath);
|
|
482
|
+
return items.filter(n => n.$type === 'ai.agent');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Build a map of role → idle agents (dynamic, no hardcoded roles) */
|
|
486
|
+
async function buildRoleIndex(): Promise<Map<string, NodeData[]>> {
|
|
487
|
+
const agents = await getAgents();
|
|
488
|
+
const index = new Map<string, NodeData[]>();
|
|
489
|
+
|
|
490
|
+
for (const a of agents) {
|
|
491
|
+
const comp = getComponent(a, AiAgent);
|
|
492
|
+
if (!comp || comp.status !== 'idle') continue;
|
|
493
|
+
const list = index.get(comp.role) ?? [];
|
|
494
|
+
list.push(a);
|
|
495
|
+
index.set(comp.role, list);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return index;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let processing = false;
|
|
502
|
+
let pendingRerun = false;
|
|
503
|
+
|
|
504
|
+
async function processInbox() {
|
|
505
|
+
if (stopped) return;
|
|
506
|
+
if (processing) { pendingRerun = true; return; }
|
|
507
|
+
processing = true;
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
const roleIndex = await buildRoleIndex();
|
|
511
|
+
if (!roleIndex.size) return;
|
|
512
|
+
|
|
513
|
+
const { items: boardTasks } = await ctx.tree.getChildren('/board/data');
|
|
514
|
+
const poolNode = await ctx.tree.get(poolPath);
|
|
515
|
+
if (!poolNode) return;
|
|
516
|
+
const pool = getComponent(poolNode, AiPool) ?? new AiPool();
|
|
517
|
+
let poolDirty = false;
|
|
518
|
+
|
|
519
|
+
for (const task of boardTasks) {
|
|
520
|
+
if (task.$type !== 'board.task') continue;
|
|
521
|
+
|
|
522
|
+
// ── Discussion mode: nextRoles on ai.assignment ──
|
|
523
|
+
const assignment = getComponent(task, AiAssignment);
|
|
524
|
+
if (assignment?.nextRoles?.length) {
|
|
525
|
+
for (const role of [...assignment.nextRoles]) {
|
|
526
|
+
const idleAgents = roleIndex.get(role);
|
|
527
|
+
if (!idleAgents?.length) continue;
|
|
528
|
+
|
|
529
|
+
const agent = idleAgents[0];
|
|
530
|
+
if (!poolAcquire(pool, agent.$path)) continue;
|
|
531
|
+
poolDirty = true;
|
|
532
|
+
idleAgents.shift();
|
|
533
|
+
|
|
534
|
+
// Mark agent busy + get fresh node for runner
|
|
535
|
+
const readyAgent = await updateComp(ctx.tree, agent.$path, AiAgent, (c) => {
|
|
536
|
+
c.status = 'working';
|
|
537
|
+
c.currentTask = task.$path;
|
|
538
|
+
});
|
|
539
|
+
if (!readyAgent) continue;
|
|
540
|
+
|
|
541
|
+
// Tag task with AI status
|
|
542
|
+
await updateNode(ctx.tree, task.$path, (n) => {
|
|
543
|
+
n.aiStatus = `💬 ${role} discussing`;
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
discussAgent(readyAgent, task, ctx.tree, poolPath).catch(err => log.error('discussAgent error:', err));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Work mode: assignee + status=todo ──
|
|
551
|
+
if (task.status === 'todo' && typeof task.assignee === 'string') {
|
|
552
|
+
const role = task.assignee as string;
|
|
553
|
+
|
|
554
|
+
// Plan mode: check if task has an approved plan
|
|
555
|
+
const plan = getComponent(task, AiPlan);
|
|
556
|
+
const needsPlan = !plan || !plan.text;
|
|
557
|
+
const planRejected = plan && plan.text && !plan.approved && !!plan.feedback;
|
|
558
|
+
const planPending = plan && plan.text && !plan.approved && !plan.feedback;
|
|
559
|
+
|
|
560
|
+
// Plan exists but not approved and no feedback → waiting for human review
|
|
561
|
+
if (planPending) continue;
|
|
562
|
+
|
|
563
|
+
const idleAgents = roleIndex.get(role);
|
|
564
|
+
if (!idleAgents?.length) continue;
|
|
565
|
+
|
|
566
|
+
const agent = idleAgents[0];
|
|
567
|
+
if (!poolAcquire(pool, agent.$path)) {
|
|
568
|
+
log.warn(`pool full, ${agent.$path} queued`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
poolDirty = true;
|
|
572
|
+
idleAgents.shift();
|
|
573
|
+
|
|
574
|
+
const readyAgent = await updateComp(ctx.tree, agent.$path, AiAgent, (c) => {
|
|
575
|
+
c.status = 'working';
|
|
576
|
+
c.currentTask = task.$path;
|
|
577
|
+
});
|
|
578
|
+
if (!readyAgent) continue;
|
|
579
|
+
|
|
580
|
+
if (needsPlan || planRejected) {
|
|
581
|
+
// Phase 1: agent writes (or rewrites) a plan
|
|
582
|
+
await updateNode(ctx.tree, task.$path, (n) => {
|
|
583
|
+
n.aiStatus = `📝 ${role} planning`;
|
|
584
|
+
n.updatedAt = Date.now();
|
|
585
|
+
});
|
|
586
|
+
planAgent(readyAgent, task, ctx.tree, poolPath).catch(err => log.error('planAgent error:', err));
|
|
587
|
+
} else {
|
|
588
|
+
// Phase 2: plan approved → execute
|
|
589
|
+
await updateNode(ctx.tree, task.$path, (n) => {
|
|
590
|
+
n.status = 'doing';
|
|
591
|
+
n.aiStatus = `⚡ ${role} working`;
|
|
592
|
+
n.updatedAt = Date.now();
|
|
593
|
+
});
|
|
594
|
+
runAgent(readyAgent, task, ctx.tree, poolPath).catch(err => log.error('runAgent error:', err));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (poolDirty) {
|
|
600
|
+
await updateComp(ctx.tree, poolPath, AiPool, (c) => {
|
|
601
|
+
c.active = pool.active;
|
|
602
|
+
c.queue = pool.queue;
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
} catch (err) {
|
|
607
|
+
log.error('processInbox error:', err);
|
|
608
|
+
} finally {
|
|
609
|
+
processing = false;
|
|
610
|
+
if (pendingRerun) { pendingRerun = false; processInbox(); }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const unsubBoard = ctx.subscribe('/board/data', (event) => {
|
|
615
|
+
if (event.type === 'set' || event.type === 'patch') processInbox();
|
|
616
|
+
}, { children: true });
|
|
617
|
+
|
|
618
|
+
const unsubAgents = ctx.subscribe(poolPath, (event) => {
|
|
619
|
+
if (event.type === 'set' || event.type === 'patch') processInbox();
|
|
620
|
+
}, { children: true });
|
|
621
|
+
|
|
622
|
+
processInbox();
|
|
623
|
+
|
|
624
|
+
// Resume agents that were working before restart (have saved sessionId)
|
|
625
|
+
for (const { agentPath, taskPath } of resumable) {
|
|
626
|
+
const agentNode = await ctx.tree.get(agentPath);
|
|
627
|
+
const taskNode = await ctx.tree.get(taskPath);
|
|
628
|
+
if (!agentNode || !taskNode) {
|
|
629
|
+
log.warn(`resume: skipping ${agentPath} — agent or task not found`);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
log.info(`resume: re-launching ${agentPath} on ${taskPath}`);
|
|
633
|
+
runAgent(agentNode, taskNode, ctx.tree, poolPath).catch(err => log.error('resume runAgent error:', err));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
stop: async () => {
|
|
638
|
+
log.info('orchestrator stopping');
|
|
639
|
+
stopped = true;
|
|
640
|
+
unsubBoard();
|
|
641
|
+
unsubAgents();
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
});
|