@animalabs/connectome-host 0.3.1

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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Lessons panel — read-only view of the parent process's lesson library.
3
+ *
4
+ * Shows active lessons sorted by confidence; deprecated entries collapse to a
5
+ * dim line with the reason. Manual refresh button — lessons mutate slowly
6
+ * (knowledge-miner pipeline is the typical writer), so we don't auto-poll.
7
+ */
8
+
9
+ import { For, Show } from 'solid-js';
10
+
11
+ export interface LessonRow {
12
+ id: string;
13
+ content: string;
14
+ confidence: number;
15
+ tags: string[];
16
+ deprecated: boolean;
17
+ deprecationReason?: string;
18
+ created?: number;
19
+ updated?: number;
20
+ }
21
+
22
+ export function LessonsPanel(props: {
23
+ /** True once a lessons-list response has come back; false during initial
24
+ * request so the panel can show a "loading…" hint. */
25
+ loaded: boolean;
26
+ /** True if the LessonsModule is mounted in the recipe. */
27
+ moduleLoaded: boolean;
28
+ lessons: LessonRow[];
29
+ /** Currently-selected scope ('local' for the parent process, or a fleet
30
+ * child name). Drives which process's lessons are shown. */
31
+ scope: string;
32
+ /** Selectable scopes — always includes 'local', plus every fleet child. */
33
+ scopes: Array<{ id: string; label: string }>;
34
+ onScopeChange(scope: string): void;
35
+ onRefresh(): void;
36
+ }) {
37
+ const active = (): LessonRow[] => props.lessons.filter(l => !l.deprecated)
38
+ .sort((a, b) => b.confidence - a.confidence);
39
+ const deprecated = (): LessonRow[] => props.lessons.filter(l => l.deprecated);
40
+
41
+ return (
42
+ <div class="h-full overflow-y-auto px-3 py-2 text-xs">
43
+ <div class="flex items-center gap-2 mb-2">
44
+ <span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
45
+ lessons
46
+ </span>
47
+ <Show when={props.moduleLoaded}>
48
+ <span class="text-neutral-600 text-[10px]">
49
+ {active().length} active
50
+ <Show when={deprecated().length > 0}> · {deprecated().length} deprecated</Show>
51
+ </span>
52
+ </Show>
53
+ <button
54
+ type="button"
55
+ class="ml-auto px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
56
+ onClick={() => props.onRefresh()}
57
+ title="Re-fetch the lesson library"
58
+ >
59
+ refresh
60
+ </button>
61
+ </div>
62
+ <ScopePicker scope={props.scope} scopes={props.scopes} onChange={props.onScopeChange} />
63
+
64
+ <Show when={!props.loaded} fallback={null}>
65
+ <div class="text-neutral-600 italic">Loading…</div>
66
+ </Show>
67
+
68
+ <Show when={props.loaded && !props.moduleLoaded}>
69
+ <div class="text-neutral-600 italic">
70
+ LessonsModule not loaded in this recipe. Enable it under
71
+ {' '}<span class="font-mono">modules.lessons</span> to populate this panel.
72
+ </div>
73
+ </Show>
74
+
75
+ <Show when={props.loaded && props.moduleLoaded && active().length === 0 && deprecated().length === 0}>
76
+ <div class="text-neutral-600 italic">
77
+ No lessons yet. The agent creates them during analysis.
78
+ </div>
79
+ </Show>
80
+
81
+ <div class="space-y-2">
82
+ <For each={active()}>{(l) => <LessonCard lesson={l} />}</For>
83
+ </div>
84
+
85
+ <Show when={deprecated().length > 0}>
86
+ <div class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold mt-4 mb-1">
87
+ deprecated
88
+ </div>
89
+ <div class="space-y-1 opacity-60">
90
+ <For each={deprecated()}>{(l) => <LessonCard lesson={l} />}</For>
91
+ </div>
92
+ </Show>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ /** Compact scope chooser shown at the top of scoped panels. The current
98
+ * scope is rendered as a pill; alternative scopes appear as siblings.
99
+ * Hidden when there's only one scope (no fleet children). */
100
+ export function ScopePicker(props: {
101
+ scope: string;
102
+ scopes: Array<{ id: string; label: string }>;
103
+ onChange(scope: string): void;
104
+ }) {
105
+ return (
106
+ <Show when={props.scopes.length > 1}>
107
+ <div class="flex flex-wrap items-center gap-1 mb-2 text-[10px] font-mono">
108
+ <span class="text-neutral-600 uppercase tracking-wider mr-1">scope</span>
109
+ <For each={props.scopes}>{(s) => (
110
+ <button
111
+ type="button"
112
+ class={`px-1.5 py-0.5 rounded ${
113
+ props.scope === s.id
114
+ ? 'bg-cyan-900/60 text-cyan-100'
115
+ : 'bg-neutral-800 hover:bg-neutral-700 text-neutral-300'
116
+ }`}
117
+ onClick={() => props.onChange(s.id)}
118
+ >
119
+ {s.label}
120
+ </button>
121
+ )}</For>
122
+ </div>
123
+ </Show>
124
+ );
125
+ }
126
+
127
+ function LessonCard(props: { lesson: LessonRow }) {
128
+ const conf = (): number => Math.round(props.lesson.confidence * 100);
129
+ const confColor = (): string => {
130
+ const c = conf();
131
+ if (c >= 80) return 'bg-emerald-900/40 text-emerald-200';
132
+ if (c >= 50) return 'bg-amber-900/40 text-amber-200';
133
+ return 'bg-neutral-800 text-neutral-400';
134
+ };
135
+ return (
136
+ <div class="border border-neutral-800 rounded px-2 py-1.5 bg-neutral-950">
137
+ <div class="flex items-baseline gap-2 mb-0.5">
138
+ <span class={`text-[10px] font-mono px-1 rounded ${confColor()}`}>
139
+ {conf()}%
140
+ </span>
141
+ <span class="text-[10px] font-mono text-neutral-600 truncate" title={props.lesson.id}>
142
+ {props.lesson.id}
143
+ </span>
144
+ </div>
145
+ <div class="text-neutral-200 leading-snug whitespace-pre-wrap">
146
+ {props.lesson.content}
147
+ </div>
148
+ <Show when={props.lesson.tags.length > 0}>
149
+ <div class="mt-1 flex flex-wrap gap-1">
150
+ <For each={props.lesson.tags}>{(tag) => (
151
+ <span class="text-[10px] font-mono text-neutral-500 bg-neutral-900 px-1 rounded">
152
+ {tag}
153
+ </span>
154
+ )}</For>
155
+ </div>
156
+ </Show>
157
+ <Show when={props.lesson.deprecationReason}>
158
+ <div class="mt-1 text-[10px] text-rose-300 italic">
159
+ deprecated: {props.lesson.deprecationReason}
160
+ </div>
161
+ </Show>
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * MCPL admin panel — read/write view of mcpl-servers.json. Mirrors the
3
+ * `/mcp list|add|remove|env` slash commands but with structured controls.
4
+ *
5
+ * Mutations are file-only — the host needs to restart for them to take
6
+ * effect. The panel makes that explicit so operators don't expect live
7
+ * reconnects.
8
+ */
9
+
10
+ import { createSignal, For, Show } from 'solid-js';
11
+
12
+ export interface McplServerRow {
13
+ id: string;
14
+ command: string;
15
+ args?: string[];
16
+ env?: Record<string, string>;
17
+ toolPrefix?: string;
18
+ reconnect?: boolean;
19
+ enabledFeatureSets?: string[];
20
+ disabledFeatureSets?: string[];
21
+ }
22
+
23
+ export function McplPanel(props: {
24
+ loaded: boolean;
25
+ configPath: string;
26
+ servers: McplServerRow[];
27
+ onRefresh(): void;
28
+ onAdd(input: { id: string; command: string; args?: string[]; env?: Record<string, string>; toolPrefix?: string }): void;
29
+ onRemove(id: string): void;
30
+ onSetEnv(id: string, env: Record<string, string>): void;
31
+ }) {
32
+ const [showAdd, setShowAdd] = createSignal(false);
33
+
34
+ return (
35
+ <div class="h-full overflow-y-auto px-3 py-2 text-xs">
36
+ <div class="flex items-center gap-2 mb-2">
37
+ <span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
38
+ MCPL servers
39
+ </span>
40
+ <span class="text-neutral-600 text-[10px]">{props.servers.length}</span>
41
+ <button
42
+ type="button"
43
+ class="ml-auto px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
44
+ onClick={() => props.onRefresh()}
45
+ >
46
+ refresh
47
+ </button>
48
+ <button
49
+ type="button"
50
+ class="px-2 py-0.5 text-[10px] bg-cyan-900/40 hover:bg-cyan-900/60 text-cyan-200 rounded font-mono"
51
+ onClick={() => setShowAdd(s => !s)}
52
+ >
53
+ {showAdd() ? 'cancel add' : '+ add'}
54
+ </button>
55
+ </div>
56
+
57
+ <Show when={!props.loaded}>
58
+ <div class="text-neutral-600 italic">Loading…</div>
59
+ </Show>
60
+
61
+ <Show when={props.loaded}>
62
+ <div class="text-[10px] text-neutral-600 italic mb-2 break-all" title={props.configPath}>
63
+ {props.configPath}
64
+ </div>
65
+
66
+ <Show when={showAdd()}>
67
+ <AddServerForm
68
+ onSubmit={(input) => {
69
+ props.onAdd(input);
70
+ setShowAdd(false);
71
+ }}
72
+ onCancel={() => setShowAdd(false)}
73
+ />
74
+ </Show>
75
+
76
+ <Show when={props.loaded && props.servers.length === 0 && !showAdd()}>
77
+ <div class="text-neutral-600 italic">No MCPL servers configured.</div>
78
+ </Show>
79
+
80
+ <div class="space-y-2">
81
+ <For each={props.servers}>{(server) => (
82
+ <ServerCard
83
+ server={server}
84
+ onRemove={() => props.onRemove(server.id)}
85
+ onSetEnv={(env) => props.onSetEnv(server.id, env)}
86
+ />
87
+ )}</For>
88
+ </div>
89
+
90
+ <Show when={props.servers.length > 0}>
91
+ <div class="mt-3 text-[10px] text-amber-300/80 italic">
92
+ Changes are written to disk; restart the host process to apply.
93
+ </div>
94
+ </Show>
95
+ </Show>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ function ServerCard(props: {
101
+ server: McplServerRow;
102
+ onRemove(): void;
103
+ onSetEnv(env: Record<string, string>): void;
104
+ }) {
105
+ const [editEnv, setEditEnv] = createSignal(false);
106
+ const [confirmDel, setConfirmDel] = createSignal(false);
107
+ const cmdLine = (): string => [props.server.command, ...(props.server.args ?? [])].join(' ');
108
+ const envEntries = (): Array<[string, string]> => Object.entries(props.server.env ?? {});
109
+
110
+ return (
111
+ <div class="border border-neutral-800 rounded px-2 py-1.5 bg-neutral-950">
112
+ <div class="flex items-baseline gap-2 mb-1">
113
+ <span class="font-mono text-cyan-300 truncate">{props.server.id}</span>
114
+ <Show when={props.server.toolPrefix}>
115
+ <span class="text-[10px] text-neutral-600">prefix={props.server.toolPrefix}</span>
116
+ </Show>
117
+ <Show when={props.server.reconnect}>
118
+ <span class="text-[10px] text-emerald-400">↻ reconnect</span>
119
+ </Show>
120
+ <button
121
+ type="button"
122
+ class="ml-auto text-[10px] px-1 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
123
+ onClick={() => setEditEnv(s => !s)}
124
+ >
125
+ {editEnv() ? 'close env' : 'env'}
126
+ </button>
127
+ <Show when={!confirmDel()} fallback={
128
+ <span class="flex gap-1">
129
+ <button
130
+ type="button"
131
+ class="text-[10px] px-1 py-0.5 bg-rose-900/60 hover:bg-rose-900 text-rose-100 rounded font-mono"
132
+ onClick={() => { props.onRemove(); setConfirmDel(false); }}
133
+ >
134
+ confirm
135
+ </button>
136
+ <button
137
+ type="button"
138
+ class="text-[10px] px-1 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
139
+ onClick={() => setConfirmDel(false)}
140
+ >
141
+
142
+ </button>
143
+ </span>
144
+ }>
145
+ <button
146
+ type="button"
147
+ class="text-[10px] px-1 py-0.5 bg-rose-900/40 hover:bg-rose-900/60 text-rose-200 rounded font-mono"
148
+ onClick={() => setConfirmDel(true)}
149
+ >
150
+ remove
151
+ </button>
152
+ </Show>
153
+ </div>
154
+ <div class="font-mono text-neutral-300 text-[11px] break-all leading-tight">
155
+ {cmdLine()}
156
+ </div>
157
+ <Show when={envEntries().length > 0 && !editEnv()}>
158
+ <div class="mt-1 flex flex-wrap gap-1">
159
+ <For each={envEntries()}>{([k]) => (
160
+ <span class="text-[10px] font-mono text-neutral-500 bg-neutral-900 px-1 rounded" title="(value hidden — open env to view)">
161
+ {k}
162
+ </span>
163
+ )}</For>
164
+ </div>
165
+ </Show>
166
+ <Show when={editEnv()}>
167
+ <EnvEditor
168
+ initial={props.server.env ?? {}}
169
+ onSave={(env) => { props.onSetEnv(env); setEditEnv(false); }}
170
+ onCancel={() => setEditEnv(false)}
171
+ />
172
+ </Show>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ function AddServerForm(props: {
178
+ onSubmit(input: { id: string; command: string; args?: string[]; env?: Record<string, string>; toolPrefix?: string }): void;
179
+ onCancel(): void;
180
+ }) {
181
+ const [id, setId] = createSignal('');
182
+ const [command, setCommand] = createSignal('');
183
+ const [argsRaw, setArgsRaw] = createSignal('');
184
+ const [envRaw, setEnvRaw] = createSignal('');
185
+ const [toolPrefix, setToolPrefix] = createSignal('');
186
+
187
+ const submit = (): void => {
188
+ if (!id().trim() || !command().trim()) return;
189
+ const args = argsRaw().trim() ? splitArgs(argsRaw().trim()) : undefined;
190
+ const env = parseEnvBlock(envRaw());
191
+ props.onSubmit({
192
+ id: id().trim(),
193
+ command: command().trim(),
194
+ ...(args && args.length > 0 ? { args } : {}),
195
+ ...(env && Object.keys(env).length > 0 ? { env } : {}),
196
+ ...(toolPrefix().trim() ? { toolPrefix: toolPrefix().trim() } : {}),
197
+ });
198
+ };
199
+
200
+ return (
201
+ <div class="border border-cyan-800 rounded px-2 py-2 bg-cyan-950/20 mb-2 space-y-1.5">
202
+ <div class="text-[10px] uppercase tracking-wider text-cyan-300 font-semibold mb-1">Add server</div>
203
+ <Field label="id" value={id()} onInput={setId} placeholder="my-server" />
204
+ <Field label="command" value={command()} onInput={setCommand} placeholder="bun" />
205
+ <Field label="args" value={argsRaw()} onInput={setArgsRaw} placeholder="run server.ts" />
206
+ <Field label="toolPrefix" value={toolPrefix()} onInput={setToolPrefix} placeholder="(optional)" />
207
+ <div>
208
+ <div class="text-[10px] text-neutral-500 mb-0.5">env (KEY=VALUE per line)</div>
209
+ <textarea
210
+ class="w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-1 text-[11px] font-mono text-neutral-100 resize-y focus:outline-none focus:ring-1 focus:ring-cyan-700"
211
+ rows="3"
212
+ value={envRaw()}
213
+ onInput={(e) => setEnvRaw(e.currentTarget.value)}
214
+ placeholder="API_KEY=..."
215
+ />
216
+ </div>
217
+ <div class="flex gap-1 pt-1">
218
+ <button
219
+ type="button"
220
+ class="px-2 py-0.5 text-[10px] bg-cyan-900/60 hover:bg-cyan-800 text-cyan-100 rounded font-mono"
221
+ onClick={submit}
222
+ >
223
+ save
224
+ </button>
225
+ <button
226
+ type="button"
227
+ class="px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
228
+ onClick={() => props.onCancel()}
229
+ >
230
+ cancel
231
+ </button>
232
+ </div>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ function EnvEditor(props: {
238
+ initial: Record<string, string>;
239
+ onSave(env: Record<string, string>): void;
240
+ onCancel(): void;
241
+ }) {
242
+ const initialText = (): string => Object.entries(props.initial)
243
+ .map(([k, v]) => `${k}=${v}`).join('\n');
244
+ const [text, setText] = createSignal(initialText());
245
+ return (
246
+ <div class="mt-2 border border-neutral-800 rounded p-1.5 bg-neutral-900/40">
247
+ <div class="text-[10px] text-neutral-500 mb-1">env (KEY=VALUE per line; empty clears)</div>
248
+ <textarea
249
+ class="w-full bg-neutral-950 border border-neutral-800 rounded px-1.5 py-1 text-[11px] font-mono text-neutral-100 resize-y focus:outline-none focus:ring-1 focus:ring-neutral-600"
250
+ rows="4"
251
+ value={text()}
252
+ onInput={(e) => setText(e.currentTarget.value)}
253
+ />
254
+ <div class="flex gap-1 mt-1">
255
+ <button
256
+ type="button"
257
+ class="px-2 py-0.5 text-[10px] bg-cyan-900/60 hover:bg-cyan-800 text-cyan-100 rounded font-mono"
258
+ onClick={() => props.onSave(parseEnvBlock(text()) ?? {})}
259
+ >
260
+ save
261
+ </button>
262
+ <button
263
+ type="button"
264
+ class="px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
265
+ onClick={() => props.onCancel()}
266
+ >
267
+ cancel
268
+ </button>
269
+ </div>
270
+ </div>
271
+ );
272
+ }
273
+
274
+ function Field(props: { label: string; value: string; onInput: (v: string) => void; placeholder?: string }) {
275
+ return (
276
+ <div class="flex items-baseline gap-2">
277
+ <label class="w-20 text-[10px] text-neutral-500 shrink-0">{props.label}</label>
278
+ <input
279
+ type="text"
280
+ class="flex-1 bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-[11px] font-mono text-neutral-100 focus:outline-none focus:ring-1 focus:ring-cyan-700"
281
+ value={props.value}
282
+ placeholder={props.placeholder}
283
+ onInput={(e) => props.onInput(e.currentTarget.value)}
284
+ />
285
+ </div>
286
+ );
287
+ }
288
+
289
+ /** Naive whitespace split — sufficient for the args field where shell-quoted
290
+ * values with spaces are uncommon. Operators editing complex commands can
291
+ * hand-edit mcpl-servers.json directly. */
292
+ function splitArgs(raw: string): string[] {
293
+ return raw.split(/\s+/).filter(s => s.length > 0);
294
+ }
295
+
296
+ /** Parse a KEY=VALUE-per-line block into an env object. Returns null if the
297
+ * block is empty (so the caller can omit the env field entirely). */
298
+ function parseEnvBlock(raw: string): Record<string, string> | null {
299
+ const out: Record<string, string> = {};
300
+ for (const line of raw.split('\n')) {
301
+ const trimmed = line.trim();
302
+ if (!trimmed || trimmed.startsWith('#')) continue;
303
+ const eqIdx = trimmed.indexOf('=');
304
+ if (eqIdx < 1) continue;
305
+ const k = trimmed.slice(0, eqIdx).trim();
306
+ const v = trimmed.slice(eqIdx + 1).trim();
307
+ out[k] = v;
308
+ }
309
+ return Object.keys(out).length > 0 ? out : null;
310
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Stream pane — shows live events for a single tree node. The same panel is
3
+ * used regardless of node kind; only the *source* of events differs (handled
4
+ * upstream by App.tsx, which routes server messages through `formatStreamEvent`
5
+ * when they match the node's StreamSource).
6
+ *
7
+ * The pane is intentionally single-instance: clicking another node retargets
8
+ * the panel rather than opening a second one. This mirrors the TUI's "peek
9
+ * a node" UX, where focus is the unit of attention.
10
+ */
11
+
12
+ import { For, Show } from 'solid-js';
13
+
14
+ export interface StreamLine {
15
+ id: number;
16
+ /** 'token' lines fold consecutive token events into one merged line for
17
+ * readable streaming output. Other event types render verbatim. */
18
+ kind: 'token' | 'event';
19
+ text: string;
20
+ color?: string;
21
+ }
22
+
23
+ export function StreamPanel(props: {
24
+ /** Display label of the focused node. */
25
+ label: string;
26
+ /** Sub-label hint — e.g. "subagent" / "fleet child" / "agent in <child>". */
27
+ scopeHint?: string;
28
+ lines: StreamLine[];
29
+ onClose(): void;
30
+ onStop?: () => void;
31
+ canStop: boolean;
32
+ }) {
33
+ return (
34
+ <div class="border-l border-neutral-800 w-96 shrink-0 bg-neutral-950 flex flex-col h-full">
35
+ <div class="border-b border-neutral-800 px-3 py-2 flex items-center gap-2">
36
+ <span class="text-[10px] uppercase tracking-wider text-neutral-500 font-semibold">stream</span>
37
+ <span class="font-mono text-sm text-neutral-200 truncate">{props.label}</span>
38
+ <Show when={props.scopeHint}>
39
+ <span class="text-[10px] text-neutral-500 italic truncate">{props.scopeHint}</span>
40
+ </Show>
41
+ <div class="ml-auto flex gap-1">
42
+ <Show when={props.canStop && props.onStop}>
43
+ <button
44
+ type="button"
45
+ class="px-2 py-0.5 bg-rose-900/40 hover:bg-rose-900/60 text-rose-200 rounded text-xs font-mono"
46
+ onClick={() => props.onStop?.()}
47
+ title="Stop this node"
48
+ >
49
+ stop
50
+ </button>
51
+ </Show>
52
+ <button
53
+ type="button"
54
+ class="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded text-xs"
55
+ onClick={() => props.onClose()}
56
+ >
57
+ close
58
+ </button>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] space-y-0.5">
63
+ <Show when={props.lines.length > 0} fallback={
64
+ <div class="text-neutral-600 italic">Waiting for events…</div>
65
+ }>
66
+ <For each={props.lines}>{(line) => (
67
+ <div class={line.color ?? 'text-neutral-300'}>
68
+ {line.text || ' '}
69
+ </div>
70
+ )}</For>
71
+ </Show>
72
+ </div>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ /** Convert a peek/child-event/trace into a renderable line. Returns null
78
+ * to mean "skip" (e.g. token events that the caller folds elsewhere). */
79
+ export function formatStreamEvent(event: { type: string; [k: string]: unknown }): StreamLine | null {
80
+ const get = <T,>(k: string): T | undefined => event[k] as T | undefined;
81
+ const id = nextStreamLineId();
82
+ switch (event.type) {
83
+ case 'inference:started':
84
+ case 'tokens':
85
+ case 'inference:tokens':
86
+ return null;
87
+ case 'tool_calls': {
88
+ const calls = get<Array<{ name: string }>>('calls') ?? [];
89
+ return { id, kind: 'event', text: `→ ${calls.map(c => c.name).join(', ')}`, color: 'text-amber-400' };
90
+ }
91
+ case 'inference:tool_calls_yielded': {
92
+ const calls = get<Array<{ name: string }>>('calls') ?? [];
93
+ return { id, kind: 'event', text: `→ ${calls.map(c => c.name).join(', ')}`, color: 'text-amber-400' };
94
+ }
95
+ case 'tool:started':
96
+ return { id, kind: 'event', text: ` ⟳ ${get('tool') ?? get('name')}`, color: 'text-amber-300' };
97
+ case 'tool:completed':
98
+ return { id, kind: 'event', text: ` ✓ ${get('tool') ?? get('name')}`, color: 'text-cyan-400' };
99
+ case 'tool:failed':
100
+ return { id, kind: 'event', text: ` ✗ ${get('tool') ?? get('name')}: ${get('error') ?? ''}`, color: 'text-rose-400' };
101
+ case 'inference:completed':
102
+ return { id, kind: 'event', text: '── inference completed ──', color: 'text-neutral-500' };
103
+ case 'inference:failed':
104
+ return { id, kind: 'event', text: `✗ inference failed: ${get('error') ?? ''}`, color: 'text-rose-400' };
105
+ case 'lifecycle': {
106
+ const phase = get<string>('phase');
107
+ return { id, kind: 'event', text: `◆ lifecycle: ${phase}`, color: phase === 'ready' ? 'text-emerald-400' : 'text-neutral-500' };
108
+ }
109
+ case 'done': {
110
+ const summary = get<string>('summary') ?? '(no summary)';
111
+ return { id, kind: 'event', text: `✓ done: ${summary.slice(0, 100)}`, color: 'text-emerald-400' };
112
+ }
113
+ default:
114
+ return { id, kind: 'event', text: `· ${event.type}`, color: 'text-neutral-600' };
115
+ }
116
+ }
117
+
118
+ let streamIdCounter = 0;
119
+ function nextStreamLineId(): number { return ++streamIdCounter; }