@treenity/mods 3.0.1 → 3.0.3

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 (125) 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/board/view.tsx +1 -1
  9. package/brahman/helpers.ts +7 -7
  10. package/brahman/service.ts +24 -24
  11. package/brahman/types.ts +21 -21
  12. package/brahman/views/action-cards.tsx +33 -23
  13. package/brahman/views/bot-view.tsx +3 -2
  14. package/brahman/views/chat-editor.tsx +119 -124
  15. package/brahman/views/menu-editor.tsx +75 -89
  16. package/brahman/views/page-layout.tsx +10 -8
  17. package/brahman/views/tstring-input.tsx +25 -15
  18. package/canary/service.ts +18 -18
  19. package/dist/board/view.js +1 -1
  20. package/dist/board/view.js.map +1 -1
  21. package/dist/brahman/helpers.d.ts +1 -1
  22. package/dist/brahman/helpers.d.ts.map +1 -1
  23. package/dist/brahman/helpers.js +6 -6
  24. package/dist/brahman/helpers.js.map +1 -1
  25. package/dist/brahman/service.js +24 -24
  26. package/dist/brahman/service.js.map +1 -1
  27. package/dist/brahman/types.d.ts +1 -1
  28. package/dist/brahman/types.d.ts.map +1 -1
  29. package/dist/brahman/types.js +21 -21
  30. package/dist/brahman/types.js.map +1 -1
  31. package/dist/brahman/views/action-cards.d.ts.map +1 -1
  32. package/dist/brahman/views/action-cards.js +7 -4
  33. package/dist/brahman/views/action-cards.js.map +1 -1
  34. package/dist/brahman/views/bot-view.d.ts.map +1 -1
  35. package/dist/brahman/views/bot-view.js +2 -1
  36. package/dist/brahman/views/bot-view.js.map +1 -1
  37. package/dist/brahman/views/chat-editor.d.ts.map +1 -1
  38. package/dist/brahman/views/chat-editor.js +27 -18
  39. package/dist/brahman/views/chat-editor.js.map +1 -1
  40. package/dist/brahman/views/menu-editor.d.ts.map +1 -1
  41. package/dist/brahman/views/menu-editor.js +12 -16
  42. package/dist/brahman/views/menu-editor.js.map +1 -1
  43. package/dist/brahman/views/page-layout.d.ts.map +1 -1
  44. package/dist/brahman/views/page-layout.js +1 -1
  45. package/dist/brahman/views/page-layout.js.map +1 -1
  46. package/dist/brahman/views/tstring-input.d.ts.map +1 -1
  47. package/dist/brahman/views/tstring-input.js +7 -3
  48. package/dist/brahman/views/tstring-input.js.map +1 -1
  49. package/dist/canary/service.js +18 -18
  50. package/dist/canary/service.js.map +1 -1
  51. package/dist/doc/fs-codec.js +1 -1
  52. package/dist/doc/fs-codec.js.map +1 -1
  53. package/dist/doc/renderers.d.ts.map +1 -1
  54. package/dist/doc/renderers.js +2 -1
  55. package/dist/doc/renderers.js.map +1 -1
  56. package/dist/doc/toolbar.d.ts.map +1 -1
  57. package/dist/doc/toolbar.js +5 -5
  58. package/dist/doc/toolbar.js.map +1 -1
  59. package/dist/launcher/types.js +2 -2
  60. package/dist/launcher/types.js.map +1 -1
  61. package/dist/launcher/view.js +2 -2
  62. package/dist/launcher/view.js.map +1 -1
  63. package/dist/mindmap/branch.d.ts +10 -0
  64. package/dist/mindmap/branch.d.ts.map +1 -1
  65. package/dist/mindmap/branch.js +42 -9
  66. package/dist/mindmap/branch.js.map +1 -1
  67. package/dist/mindmap/sidebar.d.ts.map +1 -1
  68. package/dist/mindmap/sidebar.js +4 -3
  69. package/dist/mindmap/sidebar.js.map +1 -1
  70. package/dist/mindmap/view.d.ts.map +1 -1
  71. package/dist/mindmap/view.js +35 -4
  72. package/dist/mindmap/view.js.map +1 -1
  73. package/dist/sensor-demo/service.js +6 -5
  74. package/dist/sensor-demo/service.js.map +1 -1
  75. package/dist/sensor-generator/action.js +1 -1
  76. package/dist/sensor-generator/action.js.map +1 -1
  77. package/dist/sim/service.js +41 -41
  78. package/dist/sim/service.js.map +1 -1
  79. package/dist/table/view.js.map +1 -1
  80. package/dist/todo/types.js +2 -2
  81. package/dist/todo/types.js.map +1 -1
  82. package/dist/todo/view.js +6 -4
  83. package/dist/todo/view.js.map +1 -1
  84. package/dist/whisper/inbox.js +3 -3
  85. package/dist/whisper/inbox.js.map +1 -1
  86. package/dist/whisper/route.d.ts +1 -1
  87. package/dist/whisper/route.d.ts.map +1 -1
  88. package/dist/whisper/route.js +13 -13
  89. package/dist/whisper/route.js.map +1 -1
  90. package/doc/CLAUDE.md +1 -1
  91. package/doc/fs-codec.ts +1 -1
  92. package/doc/renderers.tsx +4 -3
  93. package/doc/toolbar.tsx +12 -9
  94. package/launcher/types.ts +2 -2
  95. package/launcher/view.tsx +12 -8
  96. package/mcp/mcp-server.ts +393 -0
  97. package/mcp/server.ts +2 -0
  98. package/mcp/service.ts +18 -0
  99. package/mcp/types.ts +6 -0
  100. package/mindmap/branch.tsx +121 -22
  101. package/mindmap/mindmap.css +52 -0
  102. package/mindmap/sidebar.tsx +9 -6
  103. package/mindmap/view.tsx +40 -4
  104. package/package.json +30 -3
  105. package/sensor-demo/service.ts +6 -5
  106. package/sensor-generator/action.ts +1 -1
  107. package/sim/service.ts +41 -41
  108. package/table/view.tsx +7 -2
  109. package/todo/types.ts +2 -2
  110. package/todo/view.tsx +9 -10
  111. package/whisper/inbox.ts +3 -3
  112. package/whisper/route.ts +13 -13
  113. package/board/board.test.ts +0 -212
  114. package/brahman/brahman.test.ts +0 -855
  115. package/dist/mindmap/radial-tree.d.ts +0 -14
  116. package/dist/mindmap/radial-tree.d.ts.map +0 -1
  117. package/dist/mindmap/radial-tree.js +0 -184
  118. package/dist/mindmap/radial-tree.js.map +0 -1
  119. package/dist/mindmap/use-tree-data.d.ts +0 -14
  120. package/dist/mindmap/use-tree-data.d.ts.map +0 -1
  121. package/dist/mindmap/use-tree-data.js +0 -95
  122. package/dist/mindmap/use-tree-data.js.map +0 -1
  123. package/doc/fs-codec.test.ts +0 -119
  124. package/doc/markdown.test.ts +0 -152
  125. package/sim/sim.test.ts +0 -282
package/agent/types.ts ADDED
@@ -0,0 +1,184 @@
1
+ // AI Agent — autonomous worker in the tree.
2
+ // Role determines prompt + tool policy. Uses metatron's invokeClaude for LLM.
3
+ // Agent = node, tree = protocol.
4
+
5
+ import { registerType } from '@treenity/core/comp';
6
+
7
+ export type AgentStatus = 'idle' | 'working' | 'blocked' | 'error' | 'offline';
8
+
9
+ /** AI Agent — autonomous worker node at /agents/{name}
10
+ * LLM config (model, systemPrompt, sessionId) lives in named metatron.config component.
11
+ * Work creates metatron.task nodes under /agents/{name}/tasks/. */
12
+ export class AiAgent {
13
+ /** Open-ended role string. Guardian policies keyed by role. */
14
+ role = 'qa';
15
+ status: AgentStatus = 'offline';
16
+ /** Path to current board.task being worked on */
17
+ currentTask = '';
18
+ /** Path to current metatron.task (live log) */
19
+ taskRef = '';
20
+ lastRunAt = 0;
21
+ /** Total tokens used across all tasks */
22
+ totalTokens = 0;
23
+
24
+ /** @description Bring agent online */
25
+ online() {
26
+ this.status = 'idle';
27
+ this.currentTask = '';
28
+ }
29
+
30
+ /** @description Take agent offline */
31
+ offline() {
32
+ if (this.status === 'working')
33
+ throw new Error('cannot go offline while working');
34
+ this.status = 'offline';
35
+ }
36
+
37
+ /** @description Assign a board task to this agent */
38
+ assign(data: { /** Path to board.task */ task: string }) {
39
+ if (this.status !== 'idle')
40
+ throw new Error(`cannot assign: agent is ${this.status}`);
41
+ if (!data.task?.trim()) throw new Error('task path required');
42
+ this.currentTask = data.task.trim();
43
+ this.status = 'working';
44
+ }
45
+
46
+ /** @description Agent finished current task */
47
+ complete() {
48
+ if (this.status !== 'working')
49
+ throw new Error(`cannot complete: agent is ${this.status}`);
50
+ this.currentTask = '';
51
+ this.status = 'idle';
52
+ this.lastRunAt = Date.now();
53
+ }
54
+
55
+ /** @description Agent is blocked */
56
+ block(data?: { /** Reason */ reason?: string }) {
57
+ this.status = 'blocked';
58
+ }
59
+
60
+ /** @description Agent hit an error */
61
+ fail(data?: { /** Error message */ error?: string }) {
62
+ this.status = 'error';
63
+ this.currentTask = '';
64
+ }
65
+ }
66
+
67
+ /** Concurrency pool — lives on /agents node */
68
+ export class AiPool {
69
+ maxConcurrent = 2;
70
+ /** Paths of currently active agent nodes */
71
+ active: string[] = [];
72
+ /** Paths of agents waiting for a slot */
73
+ queue: string[] = [];
74
+ }
75
+
76
+ /** AI workflow on a board.task — routing + discussion cursor */
77
+ export class AiAssignment {
78
+ /** Who created the task */
79
+ origin = '';
80
+ /** Roles to wake for discussion (empty = no discussion pending) */
81
+ nextRoles: string[] = [];
82
+ /** agentPath → last read message index (don't re-send old messages) */
83
+ cursors: Record<string, number> = {};
84
+ }
85
+
86
+ /** Discussion thread on a task — lightweight multi-agent chat */
87
+ export class AiThread {
88
+ messages: ThreadMessage[] = [];
89
+
90
+ /** @description Post a message to the discussion */
91
+ post(data: { role: string; from: string; text: string }) {
92
+ if (!data.text?.trim()) throw new Error('empty message');
93
+ this.messages.push({ role: data.role, from: data.from, text: data.text, ts: Date.now() });
94
+ }
95
+ }
96
+
97
+ export type ThreadMessage = {
98
+ role: string;
99
+ from: string;
100
+ text: string;
101
+ ts: number;
102
+ };
103
+
104
+ /** AI execution plan — lives as named component on board.task node.
105
+ * Agent writes plan first, human reviews + approves before execution. */
106
+ export class AiPlan {
107
+ /** @format textarea */
108
+ text = '';
109
+ approved = false;
110
+ /** @format textarea */
111
+ feedback = '';
112
+ createdAt = 0;
113
+
114
+ /** @description Approve the plan — agent will proceed to execute */
115
+ approvePlan(data?: { /** Optional feedback/adjustments for the agent */ feedback?: string }) {
116
+ if (this.approved) throw new Error('plan already approved');
117
+ if (!this.text) throw new Error('no plan to approve');
118
+ this.approved = true;
119
+ if (data?.feedback) this.feedback = data.feedback;
120
+ }
121
+
122
+ /** @description Reject the plan — agent will re-plan with feedback */
123
+ rejectPlan(data?: { /** What to change */ feedback?: string }) {
124
+ if (!this.text) throw new Error('no plan to reject');
125
+ this.approved = false;
126
+ if (data?.feedback) this.feedback = data.feedback;
127
+ }
128
+ }
129
+
130
+ registerType('ai.plan', AiPlan);
131
+
132
+ /** Persistent tool policy — lives on agent (per-agent) or /agents/guardian (global) */
133
+ export class AiPolicy {
134
+ allow: string[] = [];
135
+ deny: string[] = [];
136
+ escalate: string[] = [];
137
+ /** @description Add a tool to allow list */
138
+ addAllow(data: { /** Tool name or glob pattern */ pattern: string }) {
139
+ if (!data.pattern?.trim()) throw new Error('pattern required');
140
+ const p = data.pattern.trim();
141
+ if (!this.allow.includes(p)) this.allow.push(p);
142
+ this.deny = this.deny.filter(d => d !== p);
143
+ this.escalate = this.escalate.filter(e => e !== p);
144
+ }
145
+
146
+ /** @description Add a tool to deny list */
147
+ addDeny(data: { /** Tool name or glob pattern */ pattern: string }) {
148
+ if (!data.pattern?.trim()) throw new Error('pattern required');
149
+ const p = data.pattern.trim();
150
+ if (!this.deny.includes(p)) this.deny.push(p);
151
+ this.allow = this.allow.filter(a => a !== p);
152
+ this.escalate = this.escalate.filter(e => e !== p);
153
+ }
154
+
155
+ /** @description Add a tool to escalate list (requires human approval) */
156
+ addEscalate(data: { /** Tool name or glob pattern */ pattern: string }) {
157
+ if (!data.pattern?.trim()) throw new Error('pattern required');
158
+ const p = data.pattern.trim();
159
+ if (!this.escalate.includes(p)) this.escalate.push(p);
160
+ this.allow = this.allow.filter(a => a !== p);
161
+ this.deny = this.deny.filter(d => d !== p);
162
+ }
163
+
164
+ /** @description Remove a rule from all lists */
165
+ removeRule(data: { /** Pattern to remove */ pattern: string }) {
166
+ if (!data.pattern?.trim()) throw new Error('pattern required');
167
+ const p = data.pattern.trim();
168
+ this.allow = this.allow.filter(a => a !== p);
169
+ this.deny = this.deny.filter(d => d !== p);
170
+ this.escalate = this.escalate.filter(e => e !== p);
171
+ }
172
+ }
173
+
174
+ /** Approval inbox — field-level ref points to the approvals dir */
175
+ export class AiApprovals {
176
+ source: { $type: 'ref'; $ref: string } = { $type: 'ref', $ref: '' };
177
+ }
178
+
179
+ registerType('ai.approvals', AiApprovals);
180
+ registerType('ai.policy', AiPolicy);
181
+ registerType('ai.agent', AiAgent);
182
+ registerType('ai.pool', AiPool);
183
+ registerType('ai.assignment', AiAssignment);
184
+ registerType('ai.thread', AiThread);
package/agent/view.tsx ADDED
@@ -0,0 +1,431 @@
1
+ // Agent Office — views for ai.pool, ai.agent, ai.approval, ai.thread
2
+
3
+ import { MetatronConfig } from '#metatron/types';
4
+ import { getComponent, register } from '@treenity/core';
5
+ import { Render, RenderContext, type View } from '@treenity/react/context';
6
+ import { execute, useChildren, useNavigate, usePath } from '@treenity/react/hooks';
7
+ import { minimd } from '@treenity/react/lib/minimd';
8
+ import { cn } from '@treenity/react/lib/utils';
9
+ import { useMemo, useState } from 'react';
10
+ import { AiApproval } from './guardian';
11
+ import { type AgentStatus, AiAgent, AiApprovals, AiPlan, AiPool, AiThread } from './types';
12
+
13
+ // ── Status styling ──
14
+
15
+ type StatusStyle = { bg: string; text: string; dot: string; label: string };
16
+
17
+ const AGENT_STATUS: Record<AgentStatus, StatusStyle> = {
18
+ idle: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', dot: 'bg-emerald-400', label: 'idle' },
19
+ working: { bg: 'bg-sky-500/10', text: 'text-sky-400', dot: 'bg-sky-400 animate-pulse', label: 'working' },
20
+ blocked: { bg: 'bg-amber-500/10', text: 'text-amber-400', dot: 'bg-amber-400', label: 'blocked' },
21
+ error: { bg: 'bg-red-500/10', text: 'text-red-400', dot: 'bg-red-400', label: 'error' },
22
+ offline: { bg: 'bg-zinc-700/20', text: 'text-zinc-500', dot: 'bg-zinc-600', label: 'offline' },
23
+ };
24
+
25
+ const APPROVAL_STATUS: Record<string, { bg: string; text: string; border: string }> = {
26
+ pending: { bg: 'bg-amber-500/10', text: 'text-amber-400', border: 'border-amber-500/30' },
27
+ approved: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/30' },
28
+ denied: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/30' },
29
+ };
30
+
31
+ function timeAgo(ts: number): string {
32
+ if (!ts) return '';
33
+ const d = Date.now() - ts;
34
+ if (d < 60_000) return 'just now';
35
+ if (d < 3600_000) return `${Math.floor(d / 60_000)}m ago`;
36
+ if (d < 86400_000) return `${Math.floor(d / 3600_000)}h ago`;
37
+ return new Date(ts).toLocaleDateString('en', { month: 'short', day: 'numeric' });
38
+ }
39
+
40
+ function statusStyle(status: string): StatusStyle {
41
+ return AGENT_STATUS[status as AgentStatus] ?? AGENT_STATUS.offline;
42
+ }
43
+
44
+ function StatusDot({ status }: { status: string }) {
45
+ return <span className={cn('inline-block w-2 h-2 rounded-full', statusStyle(status).dot)} />;
46
+ }
47
+
48
+ // ── PoolView (ai.pool — dashboard) ──
49
+
50
+ const PoolView: View<AiPool> = ({ value, ctx }) => {
51
+ const path = ctx!.node.$path;
52
+ const agents = useChildren(path);
53
+ const approvals = useChildren(path + '/approvals');
54
+
55
+ const agentNodes = (agents ?? []).filter(n => n.$type === 'ai.agent');
56
+ const approvalNodes = (approvals ?? []).filter(n => n.$type === 'ai.approval');
57
+ const pendingApprovals = approvalNodes.filter(n => n.status === 'pending');
58
+
59
+ const activeCount = value.active?.length ?? 0;
60
+ const queueCount = value.queue?.length ?? 0;
61
+ const maxC = value.maxConcurrent ?? 2;
62
+
63
+ return (
64
+ <div className="flex flex-col gap-6 p-5 max-w-3xl">
65
+ {/* Header */}
66
+ <div className="flex items-center justify-between">
67
+ <div>
68
+ <h2 className="text-lg font-semibold text-zinc-100 tracking-tight">Agent Office</h2>
69
+ <p className="text-xs text-zinc-500 mt-0.5 font-mono">
70
+ {activeCount}/{maxC} active · {queueCount} queued
71
+ </p>
72
+ </div>
73
+
74
+ {/* Pool capacity bar */}
75
+ <div className="flex gap-1">
76
+ {Array.from({ length: maxC }, (_, i) => (
77
+ <div
78
+ key={i}
79
+ className={cn(
80
+ 'w-3 h-8 rounded-sm transition-colors duration-300',
81
+ i < activeCount ? 'bg-sky-500/60' : 'bg-zinc-800',
82
+ )}
83
+ />
84
+ ))}
85
+ </div>
86
+ </div>
87
+
88
+ {/* Pending approvals */}
89
+ {pendingApprovals.length > 0 && (
90
+ <div className="flex flex-col gap-2">
91
+ <h3 className="text-[11px] font-medium text-amber-400 uppercase tracking-wider">
92
+ Pending Approvals ({pendingApprovals.length})
93
+ </h3>
94
+ {pendingApprovals.map((a) => (
95
+ <Render key={a.$path} value={a} />
96
+ ))}
97
+ </div>
98
+ )}
99
+
100
+ {/* Agents grid */}
101
+ <div className="flex flex-col gap-2">
102
+ <h3 className="text-[11px] font-medium text-zinc-500 uppercase tracking-wider">
103
+ Agents ({agentNodes.length})
104
+ </h3>
105
+ <RenderContext name="react:list">
106
+ {agentNodes.map((agent) => (
107
+ <Render key={agent.$path} value={agent} />
108
+ ))}
109
+ </RenderContext>
110
+ {agentNodes.length === 0 && (
111
+ <p className="text-sm text-zinc-600 italic">No agents registered</p>
112
+ )}
113
+ </div>
114
+ </div>
115
+ );
116
+ };
117
+
118
+ // ── AgentRow (compact card — react:list context) ──
119
+
120
+ const AgentRow: View<AiAgent> = ({ value, ctx }) => {
121
+ const nav = useNavigate();
122
+ const status = value.status || 'offline';
123
+ const s = statusStyle(status);
124
+
125
+ return (
126
+ <button
127
+ onClick={() => nav(ctx!.node.$path)}
128
+ className={cn(
129
+ 'flex items-center gap-3 px-3.5 py-2.5 rounded-lg border transition-all duration-150',
130
+ 'border-zinc-800/60 hover:border-zinc-700 hover:bg-zinc-800/30',
131
+ 'text-left w-full group',
132
+ )}
133
+ >
134
+ <StatusDot status={status} />
135
+
136
+ <div className="flex-1 min-w-0">
137
+ <div className="flex items-center gap-2">
138
+ <span className="text-sm font-medium text-zinc-200 truncate">
139
+ {ctx!.node.$path.split('/').at(-1)}
140
+ </span>
141
+ <span className={cn('text-[10px] font-mono px-1.5 py-0.5 rounded', s.bg, s.text)}>
142
+ {value.role}
143
+ </span>
144
+ </div>
145
+
146
+ {status === 'working' && value.currentTask && (
147
+ <p className="text-[11px] text-zinc-500 truncate mt-0.5 font-mono">
148
+ → {value.currentTask}
149
+ </p>
150
+ )}
151
+ </div>
152
+
153
+ <div className="flex items-center gap-3 text-[11px] text-zinc-600">
154
+ {value.totalTokens > 0 && (
155
+ <span className="font-mono">{(value.totalTokens / 100000).toFixed(2)}$</span>
156
+ )}
157
+ {value.lastRunAt > 0 && <span>{timeAgo(value.lastRunAt)}</span>}
158
+ </div>
159
+
160
+ <span className="text-zinc-700 group-hover:text-zinc-500 transition-colors">›</span>
161
+ </button>
162
+ );
163
+ };
164
+
165
+ // ── AgentView (ai.agent detail) ──
166
+
167
+ const AgentView: View<AiAgent> = ({ value, ctx }) => {
168
+ const path = ctx!.node.$path;
169
+ const status = value.status || 'offline';
170
+ const s = statusStyle(status);
171
+ const config = getComponent(ctx!.node, MetatronConfig);
172
+ const mtTask = usePath(value.taskRef || null);
173
+
174
+ return (
175
+ <div className="flex flex-col gap-5 p-5 max-w-2xl">
176
+ <div className="flex items-center gap-3">
177
+ <StatusDot status={status} />
178
+ <h2 className="text-lg font-semibold text-zinc-100">{path.split('/').at(-1)}</h2>
179
+ <span className={cn('text-xs font-mono px-2 py-0.5 rounded', s.bg, s.text)}>
180
+ {s.label}
181
+ </span>
182
+ </div>
183
+
184
+ <div className="grid grid-cols-2 gap-3">
185
+ <InfoCell label="Role" value={value.role} />
186
+ <InfoCell label="Model" value={config?.model || '—'} mono />
187
+ <InfoCell label="Last Run" value={value.lastRunAt ? timeAgo(value.lastRunAt) : 'never'} />
188
+ <InfoCell label="Tokens" value={value.totalTokens > 0 ? `$${(value.totalTokens / 100000).toFixed(3)}` : '—'} mono />
189
+ {value.currentTask && <InfoCell label="Current Task" value={value.currentTask} mono span2 />}
190
+ </div>
191
+
192
+ {config?.systemPrompt && (
193
+ <div className="flex flex-col gap-1.5">
194
+ <span className="text-[11px] font-medium text-zinc-500 uppercase tracking-wider">System Prompt</span>
195
+ <pre className="text-xs text-zinc-400 bg-zinc-900/50 border border-zinc-800 rounded-lg p-3 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
196
+ {config.systemPrompt}
197
+ </pre>
198
+ </div>
199
+ )}
200
+
201
+ {/* Live metatron.task log (D29) */}
202
+ {mtTask && (
203
+ <div className="flex flex-col gap-1.5">
204
+ <span className="text-[11px] font-medium text-sky-400 uppercase tracking-wider">Live Task</span>
205
+ <Render value={mtTask} />
206
+ </div>
207
+ )}
208
+
209
+ <div className="flex gap-2 pt-2">
210
+ {status === 'offline' && (
211
+ <ActionBtn label="Bring Online" color="emerald" onClick={() => execute(path, 'online')} />
212
+ )}
213
+ {status === 'idle' && (
214
+ <ActionBtn label="Take Offline" color="zinc" onClick={() => execute(path, 'offline')} />
215
+ )}
216
+ {status === 'error' && (
217
+ <ActionBtn label="Bring Online" color="emerald" onClick={() => execute(path, 'online')} />
218
+ )}
219
+ </div>
220
+ </div>
221
+ );
222
+ };
223
+
224
+ function InfoCell({ label, value, mono, span2 }: { label: string; value: string; mono?: boolean; span2?: boolean }) {
225
+ return (
226
+ <div className={cn('bg-zinc-900/40 border border-zinc-800/50 rounded-lg px-3 py-2', span2 && 'col-span-2')}>
227
+ <span className="text-[10px] text-zinc-600 uppercase tracking-wider block">{label}</span>
228
+ <span className={cn('text-sm text-zinc-300 mt-0.5 block truncate', mono && 'font-mono text-xs')}>{value}</span>
229
+ </div>
230
+ );
231
+ }
232
+
233
+ function ActionBtn({ label, color, onClick }: { label: string; color: string; onClick: () => void }) {
234
+ const colors: Record<string, string> = {
235
+ emerald: 'bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 border-emerald-500/20',
236
+ zinc: 'bg-zinc-700/30 text-zinc-400 hover:bg-zinc-700/50 border-zinc-600/20',
237
+ red: 'bg-red-600/20 text-red-400 hover:bg-red-600/30 border-red-500/20',
238
+ };
239
+ return (
240
+ <button
241
+ onClick={onClick}
242
+ className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors', colors[color] ?? colors.zinc)}
243
+ >
244
+ {label}
245
+ </button>
246
+ );
247
+ }
248
+
249
+ // ── ApprovalsView (ai.approvals — container) ──
250
+
251
+ const ApprovalsView: View<AiApprovals> = ({ value, ctx }) => {
252
+ const children = useChildren(ctx!.node.$path, { watch: true, watchNew: true });
253
+ const pending = children.filter(n => n.$type === 'ai.approval' && n.status === 'pending');
254
+ const resolved = children.filter(n => n.$type === 'ai.approval' && n.status !== 'pending');
255
+
256
+ return (
257
+ <div className="flex flex-col gap-5 p-5 max-w-3xl">
258
+ <h2 className="text-lg font-semibold text-zinc-100 tracking-tight">Approvals</h2>
259
+
260
+ {pending.length > 0 && (
261
+ <div className="flex flex-col gap-2">
262
+ <h3 className="text-[11px] font-medium text-amber-400 uppercase tracking-wider">
263
+ Pending ({pending.length})
264
+ </h3>
265
+ {pending.map(a => <Render key={a.$path} value={a} />)}
266
+ </div>
267
+ )}
268
+
269
+ {pending.length === 0 && (
270
+ <p className="text-sm text-zinc-600 italic">No pending approvals</p>
271
+ )}
272
+
273
+ {resolved.length > 0 && (
274
+ <div className="flex flex-col gap-2">
275
+ <h3 className="text-[11px] font-medium text-zinc-600 uppercase tracking-wider">
276
+ History ({resolved.length})
277
+ </h3>
278
+ {resolved.slice(0, 20).map(a => <Render key={a.$path} value={a} />)}
279
+ </div>
280
+ )}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ // ── ApprovalView (ai.approval) ──
286
+
287
+ const ApprovalView: View<AiApproval> = ({ value, ctx }) => {
288
+ const path = ctx!.node.$path;
289
+ const status = value.status || 'pending';
290
+ const s = APPROVAL_STATUS[status] ?? APPROVAL_STATUS.pending;
291
+ const isPending = status === 'pending';
292
+
293
+ return (
294
+ <div className={cn('border rounded-lg px-4 py-3 flex flex-col gap-2', s.border, s.bg)}>
295
+ <div className="flex items-center justify-between">
296
+ <div className="flex items-center gap-2">
297
+ <span className={cn('text-xs font-medium', s.text)}>{status}</span>
298
+ <span className="text-[11px] text-zinc-500 font-mono">{value.agentRole}</span>
299
+ <span className="text-[11px] text-zinc-600">→</span>
300
+ <span className="text-[11px] text-zinc-400 font-mono">{value.tool}</span>
301
+ </div>
302
+ <span className="text-[10px] text-zinc-600">{timeAgo(value.createdAt)}</span>
303
+ </div>
304
+
305
+ {value.input && (
306
+ <pre className="text-[11px] text-zinc-500 bg-black/20 rounded px-2 py-1 max-h-20 overflow-y-auto whitespace-pre-wrap">
307
+ {value.input}
308
+ </pre>
309
+ )}
310
+ {value.reason && <p className="text-[11px] text-zinc-500 italic">{value.reason}</p>}
311
+
312
+ {isPending && (
313
+ <div className="flex items-center gap-2 pt-1 flex-wrap">
314
+ <ActionBtn label="Approve" color="emerald" onClick={() => execute(path, 'approve')} />
315
+ <ActionBtn label="Deny" color="red" onClick={() => execute(path, 'deny')} />
316
+ <span className="text-[10px] text-zinc-700 mx-1">|</span>
317
+ <ActionBtn label="Approve + remember (agent)" color="emerald" onClick={() => execute(path, 'approve', { remember: 'agent' })} />
318
+ <ActionBtn label="Approve + remember (global)" color="emerald" onClick={() => execute(path, 'approve', { remember: 'global' })} />
319
+ </div>
320
+ )}
321
+ </div>
322
+ );
323
+ };
324
+
325
+ // ── ThreadView (ai.thread — message list) ──
326
+
327
+ const ThreadView: View<AiThread> = ({ value }) => {
328
+ const messages = value?.messages ?? [];
329
+
330
+ if (!messages.length) {
331
+ return <p className="text-sm text-zinc-600 italic p-4">No messages yet</p>;
332
+ }
333
+
334
+ return (
335
+ <div className="flex flex-col gap-1 p-4">
336
+ {messages.map((msg, i) => (
337
+ <div key={i} className="flex gap-2 py-1.5 group">
338
+ <span className="text-[11px] font-mono text-sky-500/70 w-16 shrink-0 text-right pt-0.5">
339
+ {msg.role}
340
+ </span>
341
+ <div className="flex-1 min-w-0">
342
+ <p className="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap">{msg.text}</p>
343
+ <span className="text-[10px] text-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity">
344
+ {msg.from} · {timeAgo(msg.ts)}
345
+ </span>
346
+ </div>
347
+ </div>
348
+ ))}
349
+ </div>
350
+ );
351
+ };
352
+
353
+ // ── PlanView (ai.plan — approve/reject) ──
354
+
355
+ const PlanView: View<AiPlan> = ({ value, ctx }) => {
356
+ const [feedback, setFeedback] = useState('');
357
+ const [busy, setBusy] = useState(false);
358
+ const html = useMemo(() => value.text ? minimd(value.text) : '', [value.text]);
359
+
360
+ if (!value.text && !value.feedback) return null;
361
+
362
+ const doAction = async (action: 'approvePlan' | 'rejectPlan') => {
363
+ if (!ctx) return;
364
+ setBusy(true);
365
+ try {
366
+ await ctx.execute(action, feedback.trim() ? { feedback: feedback.trim() } : undefined);
367
+ setFeedback('');
368
+ } finally {
369
+ setBusy(false);
370
+ }
371
+ };
372
+
373
+ if (value.approved) {
374
+ return (
375
+ <div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3">
376
+ <div className="mb-1 text-[11px] font-medium text-emerald-400 uppercase tracking-wider">Plan approved</div>
377
+ <div className="minimd max-h-40 overflow-y-auto text-sm text-zinc-300" dangerouslySetInnerHTML={{ __html: html }} />
378
+ {value.feedback && (
379
+ <div className="mt-2 border-t border-emerald-500/20 pt-2 text-xs text-zinc-500">
380
+ Feedback: {value.feedback}
381
+ </div>
382
+ )}
383
+ </div>
384
+ );
385
+ }
386
+
387
+ return (
388
+ <div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
389
+ <div className="mb-2 flex items-center justify-between">
390
+ <span className="text-[11px] font-medium text-amber-400 uppercase tracking-wider">Plan awaiting approval</span>
391
+ <span className="text-[10px] text-zinc-600">
392
+ {value.createdAt ? new Date(value.createdAt).toLocaleString() : ''}
393
+ </span>
394
+ </div>
395
+
396
+ <div
397
+ className="minimd mb-3 max-h-60 overflow-y-auto rounded border border-zinc-800 bg-zinc-900/50 p-2 text-sm text-zinc-300"
398
+ dangerouslySetInnerHTML={{ __html: html }}
399
+ />
400
+
401
+ {value.feedback && (
402
+ <div className="mb-2 rounded border border-red-500/20 bg-red-500/5 px-3 py-2 text-xs text-red-300">
403
+ <span className="font-medium text-red-400">Rejection feedback:</span> {value.feedback}
404
+ </div>
405
+ )}
406
+
407
+ <textarea
408
+ value={feedback}
409
+ onChange={e => setFeedback(e.target.value)}
410
+ placeholder="Feedback / comments (optional)..."
411
+ className="mb-2 w-full min-h-16 max-h-32 resize-none rounded border border-zinc-800 bg-zinc-900/50 p-2 text-sm text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-700"
412
+ />
413
+
414
+ <div className="flex items-center gap-2">
415
+ <ActionBtn label="Approve Plan" color="emerald" onClick={() => doAction('approvePlan')} />
416
+ <ActionBtn label="Reject" color="red" onClick={() => doAction('rejectPlan')} />
417
+ {busy && <span className="text-[10px] text-zinc-600">sending...</span>}
418
+ </div>
419
+ </div>
420
+ );
421
+ };
422
+
423
+ // ── Register views ──
424
+
425
+ register(AiPool, 'react', PoolView);
426
+ register(AiAgent, 'react', AgentView);
427
+ register(AiAgent, 'react:list', AgentRow);
428
+ register(AiApproval, 'react', ApprovalView);
429
+ register(AiApprovals, 'react', ApprovalsView);
430
+ register(AiThread, 'react', ThreadView);
431
+ register(AiPlan, 'react', PlanView);
package/board/view.tsx CHANGED
@@ -532,7 +532,7 @@ const KanbanView: View<BoardKanban> = ({ value, ctx }) => {
532
532
 
533
533
  {selectedTask && selectedNode && (
534
534
  <Dialog open onOpenChange={open => { if (!open) setSelectedTask(null); }}>
535
- <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg" aria-describedby={undefined}>
535
+ <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[960px]" aria-describedby={undefined}>
536
536
  <DialogTitle className="sr-only">Task</DialogTitle>
537
537
  <Render value={selectedNode} />
538
538
  </DialogContent>
@@ -170,7 +170,7 @@ export function buildReplyMarkup(rows: MenuRow[], type: MenuType, lang: string,
170
170
  /** @opaque Runtime-injected, not part of public schema */
171
171
  export type BrahmanCtx = {
172
172
  ctx: Context;
173
- store: Tree;
173
+ tree: Tree;
174
174
  session: Record<string, unknown>;
175
175
  sessionNode: NodeData;
176
176
  user: NodeData;
@@ -254,7 +254,7 @@ export function buildLangKeyboard(langs: string[]) {
254
254
  // ── Page execution ──
255
255
 
256
256
  export async function executePage(pagePath: string, bCtx: BrahmanCtx): Promise<void> {
257
- const pageNode = await bCtx.store.get(pagePath);
257
+ const pageNode = await bCtx.tree.get(pagePath);
258
258
  if (!pageNode) return;
259
259
 
260
260
  const pageComp = getComponent(pageNode, PageConfig);
@@ -264,7 +264,7 @@ export async function executePage(pagePath: string, bCtx: BrahmanCtx): Promise<v
264
264
  const history = (bCtx.session.history ?? []) as string[];
265
265
  history.push(pagePath);
266
266
 
267
- const { items } = await bCtx.store.getChildren(pagePath + '/_actions');
267
+ const { items } = await bCtx.tree.getChildren(pagePath + '/_actions');
268
268
  const positions = pageComp.positions ?? [];
269
269
  const tracked = new Set(positions);
270
270
  const sorted = [
@@ -286,7 +286,7 @@ export async function executePage(pagePath: string, bCtx: BrahmanCtx): Promise<v
286
286
  const userData = getComponent(bCtx.user, BrahmanUser);
287
287
  if (userData) {
288
288
  (userData as any).blocked = true;
289
- await bCtx.store.set(bCtx.user);
289
+ await bCtx.tree.set(bCtx.user);
290
290
  }
291
291
  return;
292
292
  }
@@ -296,7 +296,7 @@ export async function executePage(pagePath: string, bCtx: BrahmanCtx): Promise<v
296
296
  bCtx.session.error = { message: errorMsg };
297
297
 
298
298
  try {
299
- const { items: pages } = await bCtx.store.getChildren(`${bCtx.botPath}/pages`);
299
+ const { items: pages } = await bCtx.tree.getChildren(`${bCtx.botPath}/pages`);
300
300
  const errorPage = pages.find(p => getComponent(p, PageConfig)?.command === '/error');
301
301
  if (errorPage) await executePage(errorPage.$path, bCtx);
302
302
  } catch (innerErr) {
@@ -313,7 +313,7 @@ const _neverAbort = new AbortController().signal;
313
313
  export async function executeAction(node: NodeData, bCtx: BrahmanCtx): Promise<void> {
314
314
  const handler = resolveCtx(node.$type, 'action:run');
315
315
  if (!handler) return;
316
- await (handler as any)({ node, store: bCtx.store, signal: _neverAbort }, bCtx);
316
+ await (handler as any)({ node, tree: bCtx.tree, signal: _neverAbort }, bCtx);
317
317
  }
318
318
 
319
319
  // ── Sequential action runner with wait support ──
@@ -366,7 +366,7 @@ export async function resolveWait(bCtx: BrahmanCtx, gCtx: Context): Promise<bool
366
366
 
367
367
  // Execute remaining actions (may set a new wait if another question is encountered)
368
368
  if (remaining.length) {
369
- const nodes = (await Promise.all(remaining.map(p => bCtx.store.get(p)))).filter(Boolean) as NodeData[];
369
+ const nodes = (await Promise.all(remaining.map(p => bCtx.tree.get(p)))).filter(Boolean) as NodeData[];
370
370
  await executeActions(nodes, bCtx);
371
371
  }
372
372