@treenity/mods 3.0.3 → 3.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 (116) hide show
  1. package/dist/agent/client.d.ts +3 -0
  2. package/dist/agent/client.d.ts.map +1 -0
  3. package/dist/agent/client.js +3 -0
  4. package/dist/agent/client.js.map +1 -0
  5. package/dist/agent/guardian.d.ts +47 -0
  6. package/dist/agent/guardian.d.ts.map +1 -0
  7. package/dist/agent/guardian.js +452 -0
  8. package/dist/agent/guardian.js.map +1 -0
  9. package/dist/agent/seed.d.ts +2 -0
  10. package/dist/agent/seed.d.ts.map +1 -0
  11. package/dist/agent/seed.js +68 -0
  12. package/dist/agent/seed.js.map +1 -0
  13. package/dist/agent/server.d.ts +5 -0
  14. package/dist/agent/server.d.ts.map +1 -0
  15. package/dist/agent/server.js +5 -0
  16. package/dist/agent/server.js.map +1 -0
  17. package/dist/agent/service.d.ts +2 -0
  18. package/dist/agent/service.d.ts.map +1 -0
  19. package/dist/agent/service.js +556 -0
  20. package/dist/agent/service.js.map +1 -0
  21. package/dist/agent/types.d.ts +115 -0
  22. package/dist/agent/types.d.ts.map +1 -0
  23. package/dist/agent/types.js +168 -0
  24. package/dist/agent/types.js.map +1 -0
  25. package/dist/agent/view.d.ts +2 -0
  26. package/dist/agent/view.d.ts.map +1 -0
  27. package/dist/agent/view.js +137 -0
  28. package/dist/agent/view.js.map +1 -0
  29. package/dist/mcp/mcp-server.d.ts +16 -0
  30. package/dist/mcp/mcp-server.d.ts.map +1 -0
  31. package/dist/mcp/mcp-server.js +344 -0
  32. package/dist/mcp/mcp-server.js.map +1 -0
  33. package/dist/mcp/server.d.ts +3 -0
  34. package/dist/mcp/server.d.ts.map +1 -0
  35. package/dist/mcp/server.js +3 -0
  36. package/dist/mcp/server.js.map +1 -0
  37. package/dist/mcp/service.d.ts +2 -0
  38. package/dist/mcp/service.d.ts.map +1 -0
  39. package/dist/mcp/service.js +16 -0
  40. package/dist/mcp/service.js.map +1 -0
  41. package/dist/mcp/types.d.ts +4 -0
  42. package/dist/mcp/types.d.ts.map +1 -0
  43. package/dist/mcp/types.js +6 -0
  44. package/dist/mcp/types.js.map +1 -0
  45. package/dist/metatron/claude.d.ts +30 -0
  46. package/dist/metatron/claude.d.ts.map +1 -0
  47. package/dist/metatron/claude.js +201 -0
  48. package/dist/metatron/claude.js.map +1 -0
  49. package/dist/metatron/client.d.ts +3 -0
  50. package/dist/metatron/client.d.ts.map +1 -0
  51. package/dist/metatron/client.js +3 -0
  52. package/dist/metatron/client.js.map +1 -0
  53. package/dist/metatron/mentions.d.ts +9 -0
  54. package/dist/metatron/mentions.d.ts.map +1 -0
  55. package/dist/metatron/mentions.js +21 -0
  56. package/dist/metatron/mentions.js.map +1 -0
  57. package/dist/metatron/permissions.d.ts +16 -0
  58. package/dist/metatron/permissions.d.ts.map +1 -0
  59. package/dist/metatron/permissions.js +52 -0
  60. package/dist/metatron/permissions.js.map +1 -0
  61. package/dist/metatron/seed.d.ts +2 -0
  62. package/dist/metatron/seed.d.ts.map +1 -0
  63. package/dist/metatron/seed.js +41 -0
  64. package/dist/metatron/seed.js.map +1 -0
  65. package/dist/metatron/server.d.ts +4 -0
  66. package/dist/metatron/server.d.ts.map +1 -0
  67. package/dist/metatron/server.js +4 -0
  68. package/dist/metatron/server.js.map +1 -0
  69. package/dist/metatron/service.d.ts +2 -0
  70. package/dist/metatron/service.d.ts.map +1 -0
  71. package/dist/metatron/service.js +329 -0
  72. package/dist/metatron/service.js.map +1 -0
  73. package/dist/metatron/types.d.ts +76 -0
  74. package/dist/metatron/types.d.ts.map +1 -0
  75. package/dist/metatron/types.js +112 -0
  76. package/dist/metatron/types.js.map +1 -0
  77. package/dist/metatron/view.d.ts +4 -0
  78. package/dist/metatron/view.d.ts.map +1 -0
  79. package/dist/metatron/view.js +5 -0
  80. package/dist/metatron/view.js.map +1 -0
  81. package/dist/metatron/views/config.d.ts +2 -0
  82. package/dist/metatron/views/config.d.ts.map +1 -0
  83. package/dist/metatron/views/config.js +116 -0
  84. package/dist/metatron/views/config.js.map +1 -0
  85. package/dist/metatron/views/log.d.ts +18 -0
  86. package/dist/metatron/views/log.d.ts.map +1 -0
  87. package/dist/metatron/views/log.js +224 -0
  88. package/dist/metatron/views/log.js.map +1 -0
  89. package/dist/metatron/views/shared.d.ts +13 -0
  90. package/dist/metatron/views/shared.d.ts.map +1 -0
  91. package/dist/metatron/views/shared.js +33 -0
  92. package/dist/metatron/views/shared.js.map +1 -0
  93. package/dist/metatron/views/task.d.ts +4 -0
  94. package/dist/metatron/views/task.d.ts.map +1 -0
  95. package/dist/metatron/views/task.js +106 -0
  96. package/dist/metatron/views/task.js.map +1 -0
  97. package/dist/metatron/views/workspace.d.ts +2 -0
  98. package/dist/metatron/views/workspace.d.ts.map +1 -0
  99. package/dist/metatron/views/workspace.js +138 -0
  100. package/dist/metatron/views/workspace.js.map +1 -0
  101. package/metatron/CLAUDE.md +22 -0
  102. package/metatron/claude.ts +258 -0
  103. package/metatron/client.ts +2 -0
  104. package/metatron/mentions.ts +31 -0
  105. package/metatron/permissions.ts +76 -0
  106. package/metatron/seed.ts +50 -0
  107. package/metatron/server.ts +3 -0
  108. package/metatron/service.ts +368 -0
  109. package/metatron/types.ts +120 -0
  110. package/metatron/view.tsx +4 -0
  111. package/metatron/views/config.tsx +408 -0
  112. package/metatron/views/log.tsx +412 -0
  113. package/metatron/views/shared.tsx +40 -0
  114. package/metatron/views/task.tsx +255 -0
  115. package/metatron/views/workspace.tsx +418 -0
  116. package/package.json +6 -1
@@ -0,0 +1,76 @@
1
+ // Metatron permission system
2
+ // 1. Pending permission resolvers — shared between approve action (types.ts) and query runner (claude.ts)
3
+ // 2. Rule matching engine — evaluates tree-stored rules against tool calls
4
+ // Separate module to avoid pulling @anthropic-ai/claude-agent-sdk into browser bundle.
5
+
6
+ export type PermissionPolicy = 'allow' | 'ask-once' | 'ask-always' | 'deny';
7
+
8
+ export type PermissionRule = {
9
+ tool: string; // glob pattern: 'mcp__treenity__*', '*'
10
+ pathPattern: string; // optional input.path pattern
11
+ policy: PermissionPolicy;
12
+ };
13
+
14
+ // ── Pending permission resolvers ──
15
+
16
+ export type PermissionMeta = {
17
+ tool?: string;
18
+ input?: string;
19
+ agentPath?: string;
20
+ scope?: string;
21
+ };
22
+
23
+ export const pendingPermissions = new Map<string, (allow: boolean, meta?: PermissionMeta) => void>();
24
+
25
+ export function resolvePermission(id: string, allow: boolean, meta?: PermissionMeta) {
26
+ const resolve = pendingPermissions.get(id);
27
+ if (!resolve) return;
28
+ pendingPermissions.delete(id);
29
+ resolve(allow, meta);
30
+ }
31
+
32
+ import { globMatch } from '@treenity/core/glob';
33
+
34
+ // ── Rule specificity (more specific = higher score) ──
35
+
36
+ function ruleSpecificity(rule: PermissionRule): number {
37
+ let score = 0;
38
+ // Exact tool name > glob pattern > wildcard
39
+ if (!rule.tool.includes('*')) score += 100;
40
+ else if (rule.tool !== '*') score += 50;
41
+ // Path pattern adds specificity
42
+ if (rule.pathPattern) score += 10;
43
+ return score;
44
+ }
45
+
46
+ // ── Evaluate permission against rules ──
47
+
48
+ export function evaluatePermission(
49
+ rules: PermissionRule[],
50
+ toolName: string,
51
+ input: unknown,
52
+ ): PermissionPolicy | null {
53
+ const inputPath = (input && typeof input === 'object' && 'path' in input)
54
+ ? String((input as any).path)
55
+ : '';
56
+
57
+ // Find all matching rules
58
+ const matches = rules.filter(r => {
59
+ if (!globMatch(r.tool, toolName)) return false;
60
+ if (r.pathPattern && inputPath && !globMatch(r.pathPattern, inputPath)) return false;
61
+ return true;
62
+ });
63
+
64
+ if (!matches.length) return null; // no rule matched — caller decides default
65
+
66
+ // Sort by specificity (most specific first), then by deny > ask > allow priority
67
+ const policyPriority: Record<string, number> = { deny: 3, 'ask-always': 2, 'ask-once': 1, allow: 0 };
68
+
69
+ matches.sort((a, b) => {
70
+ const specDiff = ruleSpecificity(b) - ruleSpecificity(a);
71
+ if (specDiff !== 0) return specDiff;
72
+ return (policyPriority[b.policy] ?? 0) - (policyPriority[a.policy] ?? 0);
73
+ });
74
+
75
+ return matches[0].policy;
76
+ }
@@ -0,0 +1,50 @@
1
+ import { type NodeData } from '@treenity/core';
2
+ import { registerPrefab } from '@treenity/core/mod';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ registerPrefab('metatron', 'seed', [
7
+ { $path: 'metatron', $type: 'metatron.config',
8
+ model: 'claude-opus-4-6', systemPrompt: '', sessionId: '', lastRun: 0 },
9
+
10
+ { $path: 'metatron/tasks', $type: 'dir' },
11
+
12
+ // Query mounts — virtual folders filtered by task status
13
+ { $path: 'metatron/inbox', $type: 'mount-point',
14
+ mount: { $type: 't.mount.query' },
15
+ query: { $type: 'query', source: '/metatron/tasks', match: { $type: 'metatron.task', status: { $in: ['pending', 'running'] } } } },
16
+ { $path: 'metatron/done', $type: 'mount-point',
17
+ mount: { $type: 't.mount.query' },
18
+ query: { $type: 'query', source: '/metatron/tasks', match: { $type: 'metatron.task', status: 'done' } } },
19
+
20
+ // Skills — modular prompt fragments
21
+ { $path: 'metatron/skills', $type: 'dir' },
22
+ { $path: 'metatron/skills/tree-admin', $type: 'metatron.skill',
23
+ name: 'Tree Admin',
24
+ prompt: 'You are a Treenity tree administrator. You can create, read, update, and delete nodes using MCP tools. Use catalog and describe_type to discover available types before creating nodes.',
25
+ enabled: true, category: 'core', updatedAt: 0 },
26
+ { $path: 'metatron/skills/self-learning', $type: 'metatron.skill',
27
+ name: 'Self-Learning',
28
+ prompt: 'After completing a task successfully, reflect on what you learned. If you discovered a reusable principle, pattern, or gotcha — save it as a skill at /metatron/skills/. Check existing skills first (list_children), update rather than duplicate. Skills should be concise (1-3 sentences) and actionable.',
29
+ enabled: true, category: 'meta', updatedAt: 0 },
30
+
31
+ // Memory — persistent facts/preferences
32
+ { $path: 'metatron/memory', $type: 'dir' },
33
+
34
+ // Workspaces — multi-task columns
35
+ { $path: 'metatron/workspaces', $type: 'dir' },
36
+
37
+ { $path: '/sys/autostart/metatron', $type: 'ref', $ref: '/metatron' },
38
+ ] as NodeData[], (nodes) => {
39
+ // Load metatron prompt from docs
40
+ let systemPrompt = '';
41
+ try {
42
+ systemPrompt = readFileSync(join(process.cwd(), 'docs/metatron-prompt.md'), 'utf8');
43
+ } catch {
44
+ console.warn('[seed] docs/metatron-prompt.md not found, metatron systemPrompt will be empty');
45
+ }
46
+
47
+ return nodes.map(n =>
48
+ n.$path === 'metatron' ? { ...n, systemPrompt } : n,
49
+ );
50
+ });
@@ -0,0 +1,3 @@
1
+ import './types';
2
+ import './service';
3
+ import './seed';
@@ -0,0 +1,368 @@
1
+ // Metatron — AI task queue + Claude CLI service
2
+ // 1. action:task — creates task nodes in /metatron inbox
3
+ // 2. service — watches for pending tasks, auto-invokes Claude CLI via MCP
4
+ // Prompt lives in the node (systemPrompt). First run sends full prompt.
5
+ // Subsequent runs (with sessionId) send just "check inbox" — Claude already has context.
6
+ // Streaming: progress visible on running task nodes in real-time.
7
+ // Abort: user can stop a running task via task.stop() → abortQuery in claude.ts.
8
+ // Inject: user can queue messages via task.inject() → processed after current run.
9
+
10
+ import { createNode, type NodeData, register } from '@treenity/core';
11
+ import { type ServiceCtx } from '@treenity/core/contexts/service';
12
+ import { type ActionCtx } from '@treenity/core/server/actions';
13
+ import { buildClaims, withAcl } from '@treenity/core/server/auth';
14
+ import { debouncedWrite } from '@treenity/core/util/debounced-write';
15
+ import { closeSession, invokeClaude } from './claude';
16
+ import { uniqueMentionPaths } from './mentions';
17
+ import { type PermissionRule } from './permissions';
18
+
19
+ const log = (msg: string) => console.log(`[metatron] ${msg}`);
20
+
21
+ const CHECK_INBOX = 'Check your inbox at /metatron/inbox. Process all pending tasks.';
22
+
23
+ // ── Load enabled skills from /metatron/skills/* ──
24
+
25
+ async function loadSkills(ctx: ServiceCtx, configPath: string): Promise<string> {
26
+ try {
27
+ const { items } = await ctx.tree.getChildren(`${configPath}/skills`);
28
+ const enabled = items.filter(s => s.$type === 'metatron.skill' && s.enabled && s.prompt);
29
+ if (!enabled.length) return '';
30
+
31
+ const sections = enabled.map(s => {
32
+ const name = String(s.name || s.$path.split('/').at(-1));
33
+ return `### ${name}\n${String(s.prompt)}`;
34
+ });
35
+
36
+ log(` loaded ${enabled.length} skill(s)`);
37
+ return '\n\n## Active Skills\n\n' + sections.join('\n\n');
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+
43
+ // ── Load permission rules from /metatron/permissions/* ──
44
+
45
+ async function loadPermissions(ctx: ServiceCtx, configPath: string): Promise<PermissionRule[]> {
46
+ try {
47
+ const { items } = await ctx.tree.getChildren(`${configPath}/permissions`);
48
+ const rules = items
49
+ .filter(n => n.$type === 'metatron.permission' && n.tool)
50
+ .map(n => ({
51
+ tool: String(n.tool),
52
+ pathPattern: String(n.pathPattern || ''),
53
+ policy: (n.policy as PermissionRule['policy']) || 'allow',
54
+ }));
55
+ if (rules.length) log(` loaded ${rules.length} permission rule(s)`);
56
+ return rules;
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ // ── Resolve @/path mentions in prompts → context section ──
63
+ // Uses ACL-wrapped tree scoped to the task creator's permissions.
64
+ // withAcl.get() returns undefined for denied paths — no existence leakage.
65
+
66
+ const SENSITIVE_RE = /(password|secret|token|key|hash|credentials|apiKey|api_key)/i;
67
+
68
+ export async function resolveContext(
69
+ store: import('@treenity/core/tree').Tree,
70
+ prompts: string[],
71
+ createdBy: string | null,
72
+ ): Promise<string> {
73
+ const allPaths = new Set<string>();
74
+ for (const p of prompts) {
75
+ for (const path of uniqueMentionPaths(p)) allPaths.add(path);
76
+ }
77
+
78
+ if (!allPaths.size) return '';
79
+
80
+ // ACL-scoped tree for the task creator
81
+ const claims = createdBy ? await buildClaims(store, createdBy) : ['public'];
82
+ const userTree = withAcl(store, createdBy, claims);
83
+
84
+ const MAX_MENTIONS = 5;
85
+ const paths = [...allPaths].slice(0, MAX_MENTIONS);
86
+ const sections: string[] = [];
87
+
88
+ for (const path of paths) {
89
+ try {
90
+ const node = await userTree.get(path);
91
+ if (!node) {
92
+ sections.push(`### ${path}\n(not found or access denied)`);
93
+ continue;
94
+ }
95
+ // Include type + top-level fields, strip system and sensitive fields
96
+ const summary: Record<string, unknown> = { $type: node.$type };
97
+ // use getComponents for this loop
98
+ for (const [k, v] of Object.entries(node)) {
99
+ if (k.startsWith('$')) continue;
100
+ if (SENSITIVE_RE.test(k)) continue;
101
+ if (typeof v === 'object' && v && '$type' in v) {
102
+ // Named component — filter its keys too
103
+ const comp: Record<string, unknown> = { $type: (v as any).$type };
104
+ for (const [ck, cv] of Object.entries(v as Record<string, unknown>)) {
105
+ if (ck.startsWith('$') || SENSITIVE_RE.test(ck)) continue;
106
+ comp[ck] = typeof cv === 'string' && cv.length > 500 ? cv.slice(0, 500) + '...' : cv;
107
+ }
108
+ summary[k] = comp;
109
+ continue;
110
+ }
111
+ if (typeof v === 'string' && v.length > 500) {
112
+ summary[k] = v.slice(0, 500) + '...';
113
+ } else {
114
+ summary[k] = v;
115
+ }
116
+ }
117
+ sections.push(`### ${path}\n\`\`\`json\n${JSON.stringify(summary, null, 2)}\n\`\`\``);
118
+ } catch {
119
+ sections.push(`### ${path}\n(error reading node)`);
120
+ }
121
+ }
122
+
123
+ return '\n\n## Referenced Nodes\n\n' + sections.join('\n\n');
124
+ }
125
+
126
+ // ── Action: create a task ──
127
+
128
+ /** @description Create a task for AI processing in the Metatron inbox */
129
+ register('metatron.config', 'action:task', async (ctx: ActionCtx, data: { prompt: string }) => {
130
+ if (!data.prompt) throw new Error('prompt is required');
131
+
132
+ const id = `t-${Date.now()}`;
133
+ const taskPath = `${ctx.node.$path}/tasks/${id}`;
134
+
135
+ await ctx.tree.set(createNode(taskPath, 'metatron.task', {
136
+ prompt: data.prompt,
137
+ status: 'pending',
138
+ createdAt: Date.now(),
139
+ createdBy: ctx.userId ?? null,
140
+ }));
141
+
142
+ log(`task created: ${taskPath} — "${data.prompt.slice(0, 80)}"`);
143
+ return { taskPath };
144
+ }, { description: 'Create a task for AI', params: 'prompt' });
145
+
146
+ // ── Service: watch inbox, auto-invoke Claude ──
147
+
148
+ register('metatron.config', 'service', async (node: NodeData, ctx: ServiceCtx) => {
149
+ let running = false;
150
+ let stopped = false;
151
+ let pendingRecheck = false;
152
+ let runCount = 0;
153
+
154
+ log(`service started at ${node.$path}`);
155
+
156
+ async function updateTask(path: string, fields: Record<string, unknown>) {
157
+ const task = await ctx.tree.get(path);
158
+ if (task) await ctx.tree.set({ ...task, ...fields });
159
+ }
160
+
161
+ async function updateRunningTasks(paths: string[], output: string) {
162
+ for (const p of paths) {
163
+ const task = await ctx.tree.get(p);
164
+ if (task && task.status === 'running') {
165
+ const { $rev: _, ...rest } = task;
166
+ await ctx.tree.set({ ...rest, log: output });
167
+ }
168
+ }
169
+ }
170
+
171
+ // Check if any running tasks have queued inject messages
172
+ async function drainInjected(paths: string[]): Promise<string | null> {
173
+ for (const p of paths) {
174
+ const task = await ctx.tree.get(p);
175
+ const injected = task?.injected as string[] | undefined;
176
+ if (injected?.length) {
177
+ const messages = [...injected];
178
+ await ctx.tree.set({ ...task!, injected: [] });
179
+ return messages.join('\n\n');
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+
185
+ async function processInbox() {
186
+ if (running) {
187
+ log('already running, will recheck after');
188
+ pendingRecheck = true;
189
+ return;
190
+ }
191
+ if (stopped) return;
192
+
193
+ const tasksPath = `${node.$path}/tasks`;
194
+ const { items } = await ctx.tree.getChildren(tasksPath);
195
+ const tasks = items.filter(t => t.$type === 'metatron.task');
196
+ const pending = tasks.filter(t => t.status === 'pending');
197
+ // Recover tasks stuck in "running" from a previous crash
198
+ const stuck = tasks.filter(t => t.status === 'running');
199
+ for (const t of stuck) {
200
+ log(` recovering stuck task: ${t.$path} -> pending`);
201
+ await updateTask(t.$path, { status: 'pending', result: '' });
202
+ pending.push(t);
203
+ }
204
+
205
+ log(`inbox scan: ${pending.length} pending / ${tasks.length} total tasks`);
206
+ if (!pending.length) return;
207
+
208
+ running = true;
209
+ runCount++;
210
+ const runId = runCount;
211
+ log(`run #${runId}: processing ${pending.length} task(s)...`);
212
+
213
+ const runningPaths = pending.map(t => t.$path);
214
+
215
+ // Mark all pending tasks as "running" with initial progress
216
+ for (const t of pending) {
217
+ log(` -> running: ${t.$path}`);
218
+ await updateTask(t.$path, { status: 'running', result: 'Waiting for Claude...' });
219
+ }
220
+
221
+ try {
222
+ const config = await ctx.tree.get(node.$path) as NodeData;
223
+ const sessionId = config.sessionId as string || '';
224
+ const systemPrompt = config.systemPrompt as string || '';
225
+
226
+ const isResume = !!sessionId;
227
+ const skillsSection = isResume ? '' : await loadSkills(ctx, node.$path);
228
+ const taskPrompts = pending.map(t => String(t.prompt || ''));
229
+ const createdBy = (pending[0]?.createdBy as string) ?? null;
230
+ const contextSection = isResume ? '' : await resolveContext(ctx.tree, taskPrompts, createdBy);
231
+ const prompt = isResume ? CHECK_INBOX : (systemPrompt + skillsSection + contextSection) || CHECK_INBOX;
232
+ const permissionRules = await loadPermissions(ctx, node.$path);
233
+
234
+ log(`run #${runId}: ${isResume ? 'RESUME session ' + sessionId.slice(0, 8) + '...' : 'NEW session (' + prompt.length + ' chars)'}`);
235
+
236
+ // Stream output — debounced progress updates
237
+ let tailBuf = '';
238
+ const progress = debouncedWrite(async () => {
239
+ await updateRunningTasks(runningPaths, tailBuf);
240
+ }, 2000, 'metatron.progress');
241
+
242
+ const onOutput = (chunk: string) => {
243
+ tailBuf += chunk;
244
+ progress.trigger();
245
+ };
246
+
247
+ let result;
248
+ try {
249
+ result = await invokeClaude(prompt, {
250
+ key: node.$path,
251
+ sessionId: sessionId || undefined,
252
+ model: config.model as string || undefined,
253
+ permissionRules,
254
+ onOutput,
255
+ });
256
+ } catch (sessionErr) {
257
+ if (isResume) {
258
+ const errMsg = sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
259
+ log(` session resume failed: ${errMsg} — clearing session, retrying fresh`);
260
+ closeSession(node.$path);
261
+ const freshConfig = await ctx.tree.get(node.$path) as NodeData;
262
+ await ctx.tree.set({ ...freshConfig, sessionId: '' });
263
+
264
+ tailBuf = '';
265
+ const retrySkills = await loadSkills(ctx, node.$path);
266
+ result = await invokeClaude((systemPrompt + retrySkills) || CHECK_INBOX, {
267
+ key: node.$path,
268
+ model: config.model as string || undefined,
269
+ permissionRules,
270
+ onOutput,
271
+ });
272
+ } else {
273
+ throw sessionErr;
274
+ }
275
+ }
276
+
277
+ progress.cancel();
278
+
279
+ // Final update on config: session ID + last run
280
+ const fresh = await ctx.tree.get(node.$path) as NodeData;
281
+ await ctx.tree.set({
282
+ ...fresh,
283
+ sessionId: result.sessionId ?? fresh.sessionId,
284
+ lastRun: Date.now(),
285
+ });
286
+
287
+ // Task status: aborted → done (with log preserved), error → error, else → done
288
+ const finalStatus = result.aborted ? 'done' : result.error ? 'error' : 'done';
289
+ for (const p of runningPaths) {
290
+ const task = await ctx.tree.get(p);
291
+ if (task) {
292
+ await ctx.tree.set({
293
+ ...task,
294
+ status: finalStatus,
295
+ log: result.output,
296
+ result: result.aborted
297
+ ? (result.text || '[interrupted by user]')
298
+ : (result.text || result.output),
299
+ });
300
+ }
301
+ }
302
+
303
+ log(`run #${runId}: ${result.aborted ? 'ABORTED' : 'done'}. session=${result.sessionId?.slice(0, 8) ?? 'none'} cost=$${result.costUsd ?? '?'}`);
304
+
305
+ // Check for injected follow-up messages — if found, process them immediately
306
+ if (!result.aborted && result.sessionId) {
307
+ const followUp = await drainInjected(runningPaths);
308
+ if (followUp) {
309
+ log(`run #${runId}: processing injected follow-up`);
310
+ running = false; // allow re-entry
311
+ // Re-set tasks to running for the follow-up
312
+ for (const p of runningPaths) {
313
+ await updateTask(p, { status: 'running', result: 'Processing follow-up...' });
314
+ }
315
+ // Recursive process with pending tasks will pick them up
316
+ pendingRecheck = true;
317
+ }
318
+ }
319
+ } catch (err) {
320
+ const msg = err instanceof Error ? err.message : String(err);
321
+ log(`run #${runId}: FAILED — ${msg}`);
322
+
323
+ // Revert running tasks back to pending so they can be retried
324
+ for (const p of runningPaths) {
325
+ const current = await ctx.tree.get(p);
326
+ if (current && current.status === 'running') {
327
+ log(` -> reverting to pending: ${p}`);
328
+ await ctx.tree.set({ ...current, status: 'pending', result: `Error: ${msg}` });
329
+ }
330
+ }
331
+
332
+ const fresh = await ctx.tree.get(node.$path) as NodeData;
333
+ await ctx.tree.set({
334
+ ...fresh,
335
+ lastRun: Date.now(),
336
+ });
337
+ } finally {
338
+ running = false;
339
+ }
340
+
341
+ if (!stopped && pendingRecheck) {
342
+ pendingRecheck = false;
343
+ log('recheck triggered (new tasks arrived during run)');
344
+ processInbox();
345
+ }
346
+ }
347
+
348
+ const unsub = ctx.subscribe(`${node.$path}/tasks`, (event) => {
349
+ log(`event: ${event.type} ${event.path}`);
350
+ if (event.type === 'set' || event.type === 'patch') {
351
+ processInbox();
352
+ }
353
+ }, { children: true });
354
+
355
+ // Initial inbox check on startup
356
+ log('initial inbox check...');
357
+ processInbox();
358
+
359
+ return {
360
+ stop: async () => {
361
+ log('service stopping');
362
+ stopped = true;
363
+ unsub();
364
+ closeSession(node.$path);
365
+ log('service stopped');
366
+ },
367
+ };
368
+ });
@@ -0,0 +1,120 @@
1
+ import { getCtx, registerType } from '@treenity/core/comp';
2
+
3
+ /** AI orchestrator config — model, system prompt, session tracking */
4
+ export class MetatronConfig {
5
+ model = 'claude-opus-4-6';
6
+ /** @format textarea */
7
+ systemPrompt = '';
8
+ sessionId = '';
9
+ lastRun = 0;
10
+ }
11
+
12
+ /** AI task — prompt with status and result of LLM execution */
13
+ export class MetatronTask {
14
+ /** @format textarea */
15
+ prompt = '';
16
+ status: 'pending' | 'running' | 'done' | 'error' = 'pending';
17
+ /** @format textarea */
18
+ result = '';
19
+ /** @format textarea */
20
+ log = '';
21
+ createdAt = 0;
22
+ /** Queued user messages to process after current run */
23
+ injected: string[] = [];
24
+ /** Source task path if forked */
25
+ forkedFrom = '';
26
+ /** Log position where the fork was made */
27
+ forkIndex = 0;
28
+
29
+ /** @description Stop the running task */
30
+ async stop() {
31
+ if (this.status !== 'running') throw new Error('task is not running');
32
+ const { node } = getCtx();
33
+ const configPath = node.$path.replace(/\/tasks\/[^/]+$/, '');
34
+ const { abortQuery } = await import('./claude');
35
+ abortQuery(configPath);
36
+ }
37
+
38
+ /** @description Queue a follow-up message for the running task */
39
+ async inject(data: { /** Message text */ text: string }) {
40
+ if (!data.text?.trim()) throw new Error('text is required');
41
+ this.injected.push(data.text.trim());
42
+ }
43
+
44
+ /** @description Fork this conversation at a specific log position */
45
+ async fork(data: { /** Character position in log to fork at */ atIndex: number }) {
46
+ const { tree, node } = getCtx();
47
+ const { createNode } = await import('@treenity/core');
48
+ const configPath = node.$path.replace(/\/tasks\/[^/]+$/, '');
49
+ const id = `t-${Date.now()}`;
50
+ const forkPath = `${configPath}/tasks/${id}`;
51
+ const logSlice = this.log.slice(0, data.atIndex);
52
+
53
+ await tree.set(createNode(forkPath, 'metatron.task', {
54
+ prompt: `[forked from ${node.$path.split('/').at(-1)}] ${this.prompt}`,
55
+ status: 'done',
56
+ result: logSlice,
57
+ log: logSlice,
58
+ createdAt: Date.now(),
59
+ forkedFrom: node.$path,
60
+ forkIndex: data.atIndex,
61
+ }));
62
+
63
+ return { taskPath: forkPath };
64
+ }
65
+ }
66
+
67
+ /** Permission rule — controls which tools Metatron is allowed to use */
68
+ export class MetatronPermission {
69
+ /** Tool name or glob pattern: 'mcp__treenity__*', '*' */
70
+ tool = '';
71
+ /** Optional input.path pattern */
72
+ pathPattern = '';
73
+ policy: 'allow' | 'deny' = 'allow';
74
+ createdAt = 0;
75
+ }
76
+
77
+ /** Reusable prompt template for quick task creation */
78
+ export class MetatronTemplate {
79
+ name = '';
80
+ /** @format textarea */
81
+ prompt = '';
82
+ category = '';
83
+ }
84
+
85
+ /** Modular prompt fragment — learned skill or injected capability */
86
+ export class MetatronSkill {
87
+ name = '';
88
+ /** @format textarea */
89
+ prompt = '';
90
+ enabled = true;
91
+ category = '';
92
+ updatedAt = 0;
93
+ }
94
+
95
+ /** Multi-task workspace — side-by-side columns of conversations */
96
+ export class MetatronWorkspace {
97
+ name = '';
98
+ columns: string[] = [];
99
+
100
+ /** @description Add a task as a column */
101
+ async addColumn(data: { /** Task path */ taskPath: string }) {
102
+ if (!data.taskPath) throw new Error('taskPath is required');
103
+ if (this.columns.includes(data.taskPath)) return;
104
+ this.columns.push(data.taskPath);
105
+ }
106
+
107
+ /** @description Remove a column by task path */
108
+ async removeColumn(data: { /** Task path */ taskPath: string }) {
109
+ const idx = this.columns.indexOf(data.taskPath);
110
+ if (idx === -1) throw new Error('column not found');
111
+ this.columns.splice(idx, 1);
112
+ }
113
+ }
114
+
115
+ registerType('metatron.config', MetatronConfig);
116
+ registerType('metatron.task', MetatronTask);
117
+ registerType('metatron.permission', MetatronPermission);
118
+ registerType('metatron.template', MetatronTemplate);
119
+ registerType('metatron.skill', MetatronSkill);
120
+ registerType('metatron.workspace', MetatronWorkspace);
@@ -0,0 +1,4 @@
1
+ // Barrel — registers all metatron views
2
+ import './views/config';
3
+ import './views/task';
4
+ import './views/workspace';