@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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- 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
|
+
}
|
package/web/src/Mcpl.tsx
ADDED
|
@@ -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; }
|