@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.
Files changed (135) hide show
  1. package/agent/client.ts +2 -0
  2. package/agent/guardian.ts +492 -0
  3. package/agent/seed.ts +74 -0
  4. package/agent/server.ts +4 -0
  5. package/agent/service.ts +644 -0
  6. package/agent/types.ts +184 -0
  7. package/agent/view.tsx +431 -0
  8. package/dist/agent/client.d.ts +3 -0
  9. package/dist/agent/client.d.ts.map +1 -0
  10. package/dist/agent/client.js +3 -0
  11. package/dist/agent/client.js.map +1 -0
  12. package/dist/agent/guardian.d.ts +47 -0
  13. package/dist/agent/guardian.d.ts.map +1 -0
  14. package/dist/agent/guardian.js +452 -0
  15. package/dist/agent/guardian.js.map +1 -0
  16. package/dist/agent/seed.d.ts +2 -0
  17. package/dist/agent/seed.d.ts.map +1 -0
  18. package/dist/agent/seed.js +68 -0
  19. package/dist/agent/seed.js.map +1 -0
  20. package/dist/agent/server.d.ts +5 -0
  21. package/dist/agent/server.d.ts.map +1 -0
  22. package/dist/agent/server.js +5 -0
  23. package/dist/agent/server.js.map +1 -0
  24. package/dist/agent/service.d.ts +2 -0
  25. package/dist/agent/service.d.ts.map +1 -0
  26. package/dist/agent/service.js +556 -0
  27. package/dist/agent/service.js.map +1 -0
  28. package/dist/agent/types.d.ts +115 -0
  29. package/dist/agent/types.d.ts.map +1 -0
  30. package/dist/agent/types.js +168 -0
  31. package/dist/agent/types.js.map +1 -0
  32. package/dist/agent/view.d.ts +2 -0
  33. package/dist/agent/view.d.ts.map +1 -0
  34. package/dist/agent/view.js +137 -0
  35. package/dist/agent/view.js.map +1 -0
  36. package/dist/mcp/mcp-server.d.ts +16 -0
  37. package/dist/mcp/mcp-server.d.ts.map +1 -0
  38. package/dist/mcp/mcp-server.js +344 -0
  39. package/dist/mcp/mcp-server.js.map +1 -0
  40. package/dist/mcp/server.d.ts +3 -0
  41. package/dist/mcp/server.d.ts.map +1 -0
  42. package/dist/mcp/server.js +3 -0
  43. package/dist/mcp/server.js.map +1 -0
  44. package/dist/mcp/service.d.ts +2 -0
  45. package/dist/mcp/service.d.ts.map +1 -0
  46. package/dist/mcp/service.js +16 -0
  47. package/dist/mcp/service.js.map +1 -0
  48. package/dist/mcp/types.d.ts +4 -0
  49. package/dist/mcp/types.d.ts.map +1 -0
  50. package/dist/mcp/types.js +6 -0
  51. package/dist/mcp/types.js.map +1 -0
  52. package/dist/metatron/claude.d.ts +30 -0
  53. package/dist/metatron/claude.d.ts.map +1 -0
  54. package/dist/metatron/claude.js +201 -0
  55. package/dist/metatron/claude.js.map +1 -0
  56. package/dist/metatron/client.d.ts +3 -0
  57. package/dist/metatron/client.d.ts.map +1 -0
  58. package/dist/metatron/client.js +3 -0
  59. package/dist/metatron/client.js.map +1 -0
  60. package/dist/metatron/mentions.d.ts +9 -0
  61. package/dist/metatron/mentions.d.ts.map +1 -0
  62. package/dist/metatron/mentions.js +21 -0
  63. package/dist/metatron/mentions.js.map +1 -0
  64. package/dist/metatron/permissions.d.ts +16 -0
  65. package/dist/metatron/permissions.d.ts.map +1 -0
  66. package/dist/metatron/permissions.js +52 -0
  67. package/dist/metatron/permissions.js.map +1 -0
  68. package/dist/metatron/seed.d.ts +2 -0
  69. package/dist/metatron/seed.d.ts.map +1 -0
  70. package/dist/metatron/seed.js +41 -0
  71. package/dist/metatron/seed.js.map +1 -0
  72. package/dist/metatron/server.d.ts +4 -0
  73. package/dist/metatron/server.d.ts.map +1 -0
  74. package/dist/metatron/server.js +4 -0
  75. package/dist/metatron/server.js.map +1 -0
  76. package/dist/metatron/service.d.ts +2 -0
  77. package/dist/metatron/service.d.ts.map +1 -0
  78. package/dist/metatron/service.js +361 -0
  79. package/dist/metatron/service.js.map +1 -0
  80. package/dist/metatron/types.d.ts +76 -0
  81. package/dist/metatron/types.d.ts.map +1 -0
  82. package/dist/metatron/types.js +112 -0
  83. package/dist/metatron/types.js.map +1 -0
  84. package/dist/metatron/view.d.ts +4 -0
  85. package/dist/metatron/view.d.ts.map +1 -0
  86. package/dist/metatron/view.js +5 -0
  87. package/dist/metatron/view.js.map +1 -0
  88. package/dist/metatron/views/config.d.ts +2 -0
  89. package/dist/metatron/views/config.d.ts.map +1 -0
  90. package/dist/metatron/views/config.js +116 -0
  91. package/dist/metatron/views/config.js.map +1 -0
  92. package/dist/metatron/views/log.d.ts +18 -0
  93. package/dist/metatron/views/log.d.ts.map +1 -0
  94. package/dist/metatron/views/log.js +224 -0
  95. package/dist/metatron/views/log.js.map +1 -0
  96. package/dist/metatron/views/shared.d.ts +13 -0
  97. package/dist/metatron/views/shared.d.ts.map +1 -0
  98. package/dist/metatron/views/shared.js +33 -0
  99. package/dist/metatron/views/shared.js.map +1 -0
  100. package/dist/metatron/views/task.d.ts +4 -0
  101. package/dist/metatron/views/task.d.ts.map +1 -0
  102. package/dist/metatron/views/task.js +106 -0
  103. package/dist/metatron/views/task.js.map +1 -0
  104. package/dist/metatron/views/workspace.d.ts +2 -0
  105. package/dist/metatron/views/workspace.d.ts.map +1 -0
  106. package/dist/metatron/views/workspace.js +138 -0
  107. package/dist/metatron/views/workspace.js.map +1 -0
  108. package/mcp/mcp-server.ts +393 -0
  109. package/mcp/server.ts +2 -0
  110. package/mcp/service.ts +18 -0
  111. package/mcp/types.ts +6 -0
  112. package/metatron/CLAUDE.md +22 -0
  113. package/metatron/claude.ts +258 -0
  114. package/metatron/client.ts +2 -0
  115. package/metatron/mentions.ts +31 -0
  116. package/metatron/permissions.ts +76 -0
  117. package/metatron/seed.ts +50 -0
  118. package/metatron/server.ts +3 -0
  119. package/metatron/service.ts +406 -0
  120. package/metatron/types.ts +120 -0
  121. package/metatron/view.tsx +4 -0
  122. package/metatron/views/config.tsx +408 -0
  123. package/metatron/views/log.tsx +412 -0
  124. package/metatron/views/shared.tsx +40 -0
  125. package/metatron/views/task.tsx +255 -0
  126. package/metatron/views/workspace.tsx +418 -0
  127. package/package.json +6 -2
  128. package/dist/mindmap/radial-tree.d.ts +0 -14
  129. package/dist/mindmap/radial-tree.d.ts.map +0 -1
  130. package/dist/mindmap/radial-tree.js +0 -184
  131. package/dist/mindmap/radial-tree.js.map +0 -1
  132. package/dist/mindmap/use-tree-data.d.ts +0 -14
  133. package/dist/mindmap/use-tree-data.d.ts.map +0 -1
  134. package/dist/mindmap/use-tree-data.js +0 -95
  135. package/dist/mindmap/use-tree-data.js.map +0 -1
@@ -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
+ });