catherd-cli 0.2.1 → 1.1.0
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/README.md +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
package/src/tui/init.tsx
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { TextAttributes } from "@opentui/core";
|
|
2
|
-
import { useKeyboard, usePaste, useRenderer } from "@opentui/react";
|
|
3
|
-
import { useRef, useState } from "react";
|
|
4
|
-
import { type HarnessCost, harnessCosts } from "../core/harness.ts";
|
|
5
|
-
import { listRuns } from "../core/runstore.ts";
|
|
6
|
-
import { defaultProfile, validateProfile } from "../profile/profile.ts";
|
|
7
|
-
import { loadCatalog } from "../routing/catalog.ts";
|
|
8
|
-
import { jevKey, saveJevKey, testJevKey } from "../routing/jev.ts";
|
|
9
|
-
import type { Catalog, Profile } from "../types.ts";
|
|
10
|
-
import { type BackendStatus, detectBackends } from "./backends.ts";
|
|
11
|
-
import { matrixHint, Matrix } from "./matrix.tsx";
|
|
12
|
-
import { saveAndActivate } from "./profiles.ts";
|
|
13
|
-
import { dot, glyph, tint, type Ui } from "./theme.ts";
|
|
14
|
-
import { CatSpinner, Frame, Intro } from "./ui.tsx";
|
|
15
|
-
|
|
16
|
-
export interface InitDeps {
|
|
17
|
-
jevKey: () => string | null;
|
|
18
|
-
testJevKey: (key: string) => Promise<boolean>;
|
|
19
|
-
saveJevKey: (key: string) => void;
|
|
20
|
-
detectBackends: () => Promise<BackendStatus[]>;
|
|
21
|
-
loadCatalog: () => Catalog;
|
|
22
|
-
harnessCosts: () => HarnessCost[];
|
|
23
|
-
save: (p: Profile, c: Catalog) => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const initDeps: InitDeps = {
|
|
27
|
-
jevKey,
|
|
28
|
-
testJevKey,
|
|
29
|
-
saveJevKey,
|
|
30
|
-
detectBackends,
|
|
31
|
-
loadCatalog,
|
|
32
|
-
harnessCosts: () => harnessCosts(listRuns().map((r) => r.dir)),
|
|
33
|
-
save: saveAndActivate,
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export const PLUGIN_STEPS = ["/plugin marketplace add 47vigen/catherd", "/plugin install catherd@catherd"];
|
|
37
|
-
|
|
38
|
-
type Phase = "intro" | "key" | "testing" | "backends" | "matrix" | "done";
|
|
39
|
-
|
|
40
|
-
function title(phase: Phase, plain: boolean): string {
|
|
41
|
-
const dotSep = glyph("dot", plain);
|
|
42
|
-
return {
|
|
43
|
-
intro: "",
|
|
44
|
-
key: `Setup 1/3 ${dotSep} Jev key`,
|
|
45
|
-
testing: `Setup 1/3 ${dotSep} Jev key`,
|
|
46
|
-
backends: `Setup 2/3 ${dotSep} Backends`,
|
|
47
|
-
matrix: `Setup 3/3 ${dotSep} Your profile`,
|
|
48
|
-
done: `Setup ${dotSep} ready`,
|
|
49
|
-
}[phase];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function BackendLine({ ui, b }: { ui: Ui; b: BackendStatus }) {
|
|
53
|
-
const ok = b.installed && b.loggedIn;
|
|
54
|
-
const state = !b.installed ? "not installed" : b.loggedIn ? "logged in" : "not logged in";
|
|
55
|
-
const fix = b.fix ? ` ${glyph("dot", ui.plain)} fix: ${b.fix}` : "";
|
|
56
|
-
return (
|
|
57
|
-
<text wrapMode="none" truncate attributes={ok ? TextAttributes.NONE : TextAttributes.DIM}>
|
|
58
|
-
{`${dot(ok ? "ready" : "missing", ui.plain)} ${b.backend.padEnd(9)}${(b.version ?? "").padEnd(9)}${state}${fix}`}
|
|
59
|
-
</text>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** No masked input ships with OpenTUI, so the key is kept in state and shown as asterisks. */
|
|
64
|
-
function MaskedKeyInput({ onSubmit }: { onSubmit: (key: string) => void }) {
|
|
65
|
-
const [value, setValue] = useState("");
|
|
66
|
-
useKeyboard((key) => {
|
|
67
|
-
if (key.name === "return") {
|
|
68
|
-
onSubmit(value);
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
if (key.name === "backspace") {
|
|
72
|
-
setValue((v) => v.slice(0, -1));
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
if (!key.ctrl && !key.meta) {
|
|
76
|
-
const ch = key.name === "space" ? " " : key.sequence;
|
|
77
|
-
if (ch && ch.length === 1) setValue((v) => v + ch);
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
usePaste((e) => setValue((v) => v + new TextDecoder().decode(e.bytes).replace(/\s+/g, "")));
|
|
81
|
-
return <text>{"*".repeat(value.length)}</text>;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function Init({ ui, deps = {} }: { ui: Ui; deps?: Partial<InitDeps> }) {
|
|
85
|
-
const d = { ...initDeps, ...deps };
|
|
86
|
-
const renderer = useRenderer();
|
|
87
|
-
const started = useRef(false);
|
|
88
|
-
const [phase, setPhase] = useState<Phase>("intro");
|
|
89
|
-
const [note, setNote] = useState("");
|
|
90
|
-
const [catalog, setCatalog] = useState(d.loadCatalog);
|
|
91
|
-
const [costs] = useState(d.harnessCosts);
|
|
92
|
-
const [profile, setProfile] = useState<Profile>(defaultProfile);
|
|
93
|
-
const [backends, setBackends] = useState<BackendStatus[]>([]);
|
|
94
|
-
const [errors, setErrors] = useState<string[]>([]);
|
|
95
|
-
const [showHelp, setShowHelp] = useState(false);
|
|
96
|
-
const pink = tint("pink", ui.depth);
|
|
97
|
-
|
|
98
|
-
// Any key finishes, once the "done" message has actually had a chance to sit on screen:
|
|
99
|
-
// destroying the renderer immediately tears down the render tree, so a frame captured
|
|
100
|
-
// right after would show nothing, and a real terminal would lose the message the same way.
|
|
101
|
-
useKeyboard(() => {
|
|
102
|
-
if (phase === "done") renderer.destroy();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const check = async (key: string, typed: boolean) => {
|
|
106
|
-
setPhase("testing");
|
|
107
|
-
const ok = await d.testJevKey(key).catch(() => false);
|
|
108
|
-
if (!ok) {
|
|
109
|
-
setNote(
|
|
110
|
-
typed
|
|
111
|
-
? "Jev did not accept that key. Check it at typesafe.ai and paste it again."
|
|
112
|
-
: "The saved Jev key did not answer. Paste a new one.",
|
|
113
|
-
);
|
|
114
|
-
setPhase("key");
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
if (typed) d.saveJevKey(key);
|
|
118
|
-
setNote("");
|
|
119
|
-
setPhase("backends");
|
|
120
|
-
setBackends(await d.detectBackends());
|
|
121
|
-
setPhase("matrix");
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const afterIntro = () => {
|
|
125
|
-
if (started.current) return;
|
|
126
|
-
started.current = true;
|
|
127
|
-
const saved = d.jevKey();
|
|
128
|
-
if (saved) void check(saved, false);
|
|
129
|
-
else setPhase("key");
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const accept = () => {
|
|
133
|
-
const errs = validateProfile(profile, catalog);
|
|
134
|
-
setErrors(errs);
|
|
135
|
-
if (errs.length > 0) return;
|
|
136
|
-
d.save(profile, catalog);
|
|
137
|
-
setPhase("done");
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
if (phase === "intro") {
|
|
141
|
-
return <Intro ui={ui} onDone={afterIntro} />;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const hint =
|
|
145
|
-
phase === "matrix" ? matrixHint(ui, "accept", showHelp) : phase === "done" ? "press any key" : "";
|
|
146
|
-
return (
|
|
147
|
-
<Frame ui={ui} title={title(phase, ui.plain)} hint={hint}>
|
|
148
|
-
{phase === "key" ? (
|
|
149
|
-
<box style={{ flexDirection: "column" }}>
|
|
150
|
-
<text>Paste your TypeSafe API key. Jev uses it to pick each lane's model and effort.</text>
|
|
151
|
-
<box style={{ flexDirection: "row" }}>
|
|
152
|
-
<text>key: </text>
|
|
153
|
-
<MaskedKeyInput
|
|
154
|
-
onSubmit={(k) => {
|
|
155
|
-
if (k.trim()) void check(k.trim(), true);
|
|
156
|
-
}}
|
|
157
|
-
/>
|
|
158
|
-
</box>
|
|
159
|
-
</box>
|
|
160
|
-
) : null}
|
|
161
|
-
{phase === "testing" ? <CatSpinner ui={ui} label="asking Jev a test question" /> : null}
|
|
162
|
-
{phase === "backends" ? <CatSpinner ui={ui} label="sniffing out codex and opencode" /> : null}
|
|
163
|
-
{phase === "matrix" ? (
|
|
164
|
-
<box style={{ flexDirection: "column" }}>
|
|
165
|
-
{backends.map((b) => (
|
|
166
|
-
<BackendLine key={b.backend} ui={ui} b={b} />
|
|
167
|
-
))}
|
|
168
|
-
<Matrix
|
|
169
|
-
ui={ui}
|
|
170
|
-
profile={profile}
|
|
171
|
-
catalog={catalog}
|
|
172
|
-
backends={backends}
|
|
173
|
-
costs={costs}
|
|
174
|
-
errors={errors}
|
|
175
|
-
reloadCatalog={d.loadCatalog}
|
|
176
|
-
onChange={(p) => {
|
|
177
|
-
setProfile(p);
|
|
178
|
-
setErrors([]);
|
|
179
|
-
}}
|
|
180
|
-
onCatalog={setCatalog}
|
|
181
|
-
onSubmit={accept}
|
|
182
|
-
onQuit={() => {
|
|
183
|
-
process.exitCode = 1;
|
|
184
|
-
renderer.destroy();
|
|
185
|
-
}}
|
|
186
|
-
onToggleHelp={() => setShowHelp((v) => !v)}
|
|
187
|
-
/>
|
|
188
|
-
</box>
|
|
189
|
-
) : null}
|
|
190
|
-
{phase === "done" ? (
|
|
191
|
-
<box style={{ flexDirection: "column" }}>
|
|
192
|
-
<text>{`Saved profile ${profile.name} and its Claude agents.`}</text>
|
|
193
|
-
<text>Now add the plugin in Claude Code:</text>
|
|
194
|
-
{PLUGIN_STEPS.map((s) => (
|
|
195
|
-
<text key={s} fg={tint("ginger", ui.depth)}>{` ${s}`}</text>
|
|
196
|
-
))}
|
|
197
|
-
<text>Then start a new Claude Code session: it picks up the agents.</text>
|
|
198
|
-
<text attributes={TextAttributes.DIM}>Press any key to finish.</text>
|
|
199
|
-
</box>
|
|
200
|
-
) : null}
|
|
201
|
-
{note ? (
|
|
202
|
-
<text fg={pink} wrapMode="none" truncate>
|
|
203
|
-
{note}
|
|
204
|
-
</text>
|
|
205
|
-
) : null}
|
|
206
|
-
</Frame>
|
|
207
|
-
);
|
|
208
|
-
}
|
package/src/tui/matrix-model.ts
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
import { availableParallelism } from "node:os";
|
|
2
|
-
import { capableFor, entryFor, modelOf } from "../routing/catalog.ts";
|
|
3
|
-
import {
|
|
4
|
-
type Backend,
|
|
5
|
-
type Catalog,
|
|
6
|
-
type CatalogModel,
|
|
7
|
-
type NotifyMoment,
|
|
8
|
-
type Profile,
|
|
9
|
-
ROLES,
|
|
10
|
-
type Role,
|
|
11
|
-
type RungId,
|
|
12
|
-
rungOf,
|
|
13
|
-
splitRung,
|
|
14
|
-
} from "../types.ts";
|
|
15
|
-
import type { BackendStatus } from "./backends.ts";
|
|
16
|
-
|
|
17
|
-
export const NOTIFY: NotifyMoment[] = ["milestone", "finish", "blocked"];
|
|
18
|
-
const BACKENDS: Backend[] = ["claude", "codex", "opencode"];
|
|
19
|
-
const HARNESS_BACKENDS: Array<"codex" | "opencode"> = ["codex", "opencode"];
|
|
20
|
-
|
|
21
|
-
export type BudgetField = "minutes" | "tokens" | "usd";
|
|
22
|
-
export const BUDGET_FIELDS: BudgetField[] = ["minutes", "tokens", "usd"];
|
|
23
|
-
|
|
24
|
-
/** The left pane's rows (ROLES/ROUTING/HARNESS/BUDGET/FAILOVER/NOTIFY): a fixed skeleton,
|
|
25
|
-
* independent of what is open or filtered. Everything else ("detail" rows) belongs to the
|
|
26
|
-
* right pane, and only exists for whichever top row is currently selected. */
|
|
27
|
-
export type TopRow =
|
|
28
|
-
| { kind: "role"; role: Role }
|
|
29
|
-
| { kind: "objective" }
|
|
30
|
-
| { kind: "lock" }
|
|
31
|
-
| { kind: "harness"; backend: "codex" | "opencode" }
|
|
32
|
-
| { kind: "budget-summary" }
|
|
33
|
-
| { kind: "failover-summary" }
|
|
34
|
-
| { kind: "notify"; moment: NotifyMoment };
|
|
35
|
-
|
|
36
|
-
export type DetailRow =
|
|
37
|
-
| { kind: "backend"; role: Role; backend: Backend }
|
|
38
|
-
| { kind: "model"; role: Role; model: CatalogModel }
|
|
39
|
-
| { kind: "effort"; role: Role; model: CatalogModel; effort: string }
|
|
40
|
-
| { kind: "budget"; field: BudgetField }
|
|
41
|
-
| { kind: "failover"; rung: RungId };
|
|
42
|
-
|
|
43
|
-
export type Row = TopRow | DetailRow;
|
|
44
|
-
|
|
45
|
-
export type Section = "ROLES" | "ROUTING" | "HARNESS" | "BUDGET" | "FAILOVER" | "NOTIFY";
|
|
46
|
-
|
|
47
|
-
export function sectionOf(r: TopRow): Section {
|
|
48
|
-
switch (r.kind) {
|
|
49
|
-
case "role":
|
|
50
|
-
return "ROLES";
|
|
51
|
-
case "objective":
|
|
52
|
-
case "lock":
|
|
53
|
-
return "ROUTING";
|
|
54
|
-
case "harness":
|
|
55
|
-
return "HARNESS";
|
|
56
|
-
case "budget-summary":
|
|
57
|
-
return "BUDGET";
|
|
58
|
-
case "failover-summary":
|
|
59
|
-
return "FAILOVER";
|
|
60
|
-
case "notify":
|
|
61
|
-
return "NOTIFY";
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export type Toggled = { profile: Profile } | { needsTreatLike: RungId } | { refused: string };
|
|
66
|
-
|
|
67
|
-
export function rowId(r: Row): string {
|
|
68
|
-
switch (r.kind) {
|
|
69
|
-
case "role":
|
|
70
|
-
return `role:${r.role}`;
|
|
71
|
-
case "backend":
|
|
72
|
-
return `backend:${r.role}:${r.backend}`;
|
|
73
|
-
case "harness":
|
|
74
|
-
return `harness:${r.backend}`;
|
|
75
|
-
case "model":
|
|
76
|
-
return `model:${r.role}:${r.model.id}`;
|
|
77
|
-
case "effort":
|
|
78
|
-
return `effort:${r.role}:${rungOf(r.model.id, r.effort)}`;
|
|
79
|
-
case "budget":
|
|
80
|
-
return `budget:${r.field}`;
|
|
81
|
-
case "failover":
|
|
82
|
-
return `failover:${r.rung}`;
|
|
83
|
-
case "notify":
|
|
84
|
-
return `notify:${r.moment}`;
|
|
85
|
-
default:
|
|
86
|
-
return r.kind;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The `open` key a role/model row expands under `space`/`→`. Only these two kinds fold —
|
|
91
|
-
* budget and failover always show their whole detail once selected, and harness has none. */
|
|
92
|
-
export function openKey(r: Row): string | null {
|
|
93
|
-
if (r.kind === "role") return `role:${r.role}`;
|
|
94
|
-
if (r.kind === "model" || r.kind === "effort") return `model:${r.role}:${r.model.id}`;
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function readySet(backends: BackendStatus[]): Set<Backend> {
|
|
99
|
-
return new Set<Backend>([
|
|
100
|
-
"claude",
|
|
101
|
-
...backends.filter((b) => b.installed && b.loggedIn).map((b) => b.backend),
|
|
102
|
-
]);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** The left pane: always these 17 rows, in this order, regardless of `open` or a filter. */
|
|
106
|
-
export function topLevelRows(): TopRow[] {
|
|
107
|
-
return [
|
|
108
|
-
...ROLES.map((role): TopRow => ({ kind: "role", role })),
|
|
109
|
-
{ kind: "objective" },
|
|
110
|
-
{ kind: "lock" },
|
|
111
|
-
...HARNESS_BACKENDS.map((backend): TopRow => ({ kind: "harness", backend })),
|
|
112
|
-
{ kind: "budget-summary" },
|
|
113
|
-
{ kind: "failover-summary" },
|
|
114
|
-
...NOTIFY.map((moment): TopRow => ({ kind: "notify", moment })),
|
|
115
|
-
];
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The right pane for a selected role: its capable models grouped by backend, open to their
|
|
119
|
-
* effort ladder once expanded — the harness toggle no longer lives here, it is its own top row. */
|
|
120
|
-
export function roleDetailRows(
|
|
121
|
-
p: Profile,
|
|
122
|
-
c: Catalog,
|
|
123
|
-
role: Role,
|
|
124
|
-
open: ReadonlySet<string>,
|
|
125
|
-
filter = "",
|
|
126
|
-
): DetailRow[] {
|
|
127
|
-
const f = filter.toLowerCase();
|
|
128
|
-
const out: DetailRow[] = [];
|
|
129
|
-
const models = c.models.filter((m) => capableFor(role, m) && m.id.toLowerCase().includes(f));
|
|
130
|
-
for (const backend of BACKENDS) {
|
|
131
|
-
const group = models.filter((m) => m.backend === backend);
|
|
132
|
-
if (group.length === 0) continue;
|
|
133
|
-
out.push({ kind: "backend", role, backend });
|
|
134
|
-
for (const model of group) {
|
|
135
|
-
out.push({ kind: "model", role, model });
|
|
136
|
-
if (!open.has(`model:${role}:${model.id}`)) continue;
|
|
137
|
-
for (const effort of model.efforts) out.push({ kind: "effort", role, model, effort });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return out;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function budgetDetailRows(): DetailRow[] {
|
|
144
|
-
return BUDGET_FIELDS.map((field) => ({ kind: "budget", field }));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function failoverDetailRows(p: Profile, c: Catalog): DetailRow[] {
|
|
148
|
-
return enabledFailoverRungs(p, c).map((rung) => ({ kind: "failover", rung }));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** spec §11b: every codex/opencode rung ticked by a currently-enabled role gets a failover row. */
|
|
152
|
-
export function enabledFailoverRungs(p: Profile, c: Catalog): RungId[] {
|
|
153
|
-
const set = new Set<RungId>();
|
|
154
|
-
for (const role of ROLES) {
|
|
155
|
-
if (!p.roles[role].enabled) continue;
|
|
156
|
-
for (const rung of ticked(p, role)) {
|
|
157
|
-
const backend = modelOf(c, splitRung(rung).model)?.backend;
|
|
158
|
-
if (backend === "codex" || backend === "opencode") set.add(rung);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return [...set].sort();
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** A failover stand-in must be scored (or treat-like) and on a different backend — the same rule
|
|
165
|
-
* `validateProfile` enforces, kept in sync here so the picker only ever offers a valid choice. */
|
|
166
|
-
export function failoverOptions(c: Catalog, rung: RungId): RungId[] {
|
|
167
|
-
const from = modelOf(c, splitRung(rung).model)?.backend;
|
|
168
|
-
const candidates = new Set<RungId>([...c.entries.map((e) => e.rung), ...Object.keys(c.treatLike)]);
|
|
169
|
-
return [...candidates]
|
|
170
|
-
.filter((r) => {
|
|
171
|
-
const backend = modelOf(c, splitRung(r).model)?.backend;
|
|
172
|
-
return backend !== undefined && backend !== from;
|
|
173
|
-
})
|
|
174
|
-
.sort();
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** "no cap" or e.g. "30 min · $5" — the BUDGET row's one-line left-pane summary. `sep` is the
|
|
178
|
-
* caller's glyph("dot", ui.plain) — this module stays presentation-neutral, so it takes the
|
|
179
|
-
* separator rather than importing theme.ts's plain/unicode split itself. */
|
|
180
|
-
export function budgetSummary(p: Profile, sep = " · "): string {
|
|
181
|
-
const b = p.budget;
|
|
182
|
-
const parts = [
|
|
183
|
-
b?.minutes !== undefined ? `${b.minutes} min` : null,
|
|
184
|
-
b?.tokens !== undefined ? `${Math.round(b.tokens / 1000)}k tok` : null,
|
|
185
|
-
b?.usd !== undefined ? `$${b.usd}` : null,
|
|
186
|
-
].filter((x): x is string => x !== null);
|
|
187
|
-
return parts.length > 0 ? parts.join(sep) : "no cap";
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** "none" or "N set" — the FAILOVER row's one-line left-pane summary. */
|
|
191
|
-
export function failoverSummary(p: Profile): string {
|
|
192
|
-
const n = Object.keys(p.failover ?? {}).length;
|
|
193
|
-
return n === 0 ? "none" : `${n} set`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export function isTicked(p: Profile, r: Row): boolean {
|
|
197
|
-
switch (r.kind) {
|
|
198
|
-
case "role":
|
|
199
|
-
return p.roles[r.role].enabled;
|
|
200
|
-
case "harness":
|
|
201
|
-
return p.harness[r.backend].isolated;
|
|
202
|
-
case "model":
|
|
203
|
-
return (p.roles[r.role].models[r.model.id]?.length ?? 0) > 0;
|
|
204
|
-
case "effort":
|
|
205
|
-
return p.roles[r.role].models[r.model.id]?.includes(r.effort) ?? false;
|
|
206
|
-
case "notify":
|
|
207
|
-
return p.notify.includes(r.moment);
|
|
208
|
-
default:
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export function ticked(p: Profile, role: Role): RungId[] {
|
|
214
|
-
return Object.entries(p.roles[role].models).flatMap(([model, efforts]) =>
|
|
215
|
-
efforts.map((e) => rungOf(model, e)),
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function nextLock(v: number | "cpus/2", max = availableParallelism()): number | "cpus/2" {
|
|
220
|
-
if (v === "cpus/2") return 1;
|
|
221
|
-
return v >= max ? "cpus/2" : v + 1;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export function treatLikeOptions(c: Catalog, role: Role): RungId[] {
|
|
225
|
-
return c.entries
|
|
226
|
-
.map((e) => e.rung)
|
|
227
|
-
.filter((rung) => {
|
|
228
|
-
const m = modelOf(c, splitRung(rung).model);
|
|
229
|
-
return m !== undefined && capableFor(role, m);
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function toggle(p: Profile, c: Catalog, r: Row, ready: ReadonlySet<Backend>): Toggled {
|
|
234
|
-
const next = structuredClone(p);
|
|
235
|
-
switch (r.kind) {
|
|
236
|
-
case "role":
|
|
237
|
-
if (r.role === "worker") return { refused: "The worker is always on." };
|
|
238
|
-
next.roles[r.role].enabled = !p.roles[r.role].enabled;
|
|
239
|
-
return { profile: next };
|
|
240
|
-
case "harness":
|
|
241
|
-
next.harness[r.backend].isolated = !p.harness[r.backend].isolated;
|
|
242
|
-
return { profile: next };
|
|
243
|
-
case "model":
|
|
244
|
-
delete next.roles[r.role].models[r.model.id];
|
|
245
|
-
return { profile: next };
|
|
246
|
-
case "effort": {
|
|
247
|
-
const models = next.roles[r.role].models;
|
|
248
|
-
const have = models[r.model.id] ?? [];
|
|
249
|
-
if (have.includes(r.effort)) {
|
|
250
|
-
const rest = have.filter((e) => e !== r.effort);
|
|
251
|
-
if (rest.length > 0) models[r.model.id] = rest;
|
|
252
|
-
else delete models[r.model.id];
|
|
253
|
-
return { profile: next };
|
|
254
|
-
}
|
|
255
|
-
if (!ready.has(r.model.backend)) return { refused: `${r.model.backend} is not ready yet.` };
|
|
256
|
-
const rung = rungOf(r.model.id, r.effort);
|
|
257
|
-
if (!entryFor(c, rung)) return { needsTreatLike: rung };
|
|
258
|
-
models[r.model.id] = r.model.efforts.filter((e) => e === r.effort || have.includes(e));
|
|
259
|
-
return { profile: next };
|
|
260
|
-
}
|
|
261
|
-
case "objective":
|
|
262
|
-
next.objective = p.objective === "cost" ? "speed" : "cost";
|
|
263
|
-
return { profile: next };
|
|
264
|
-
case "lock":
|
|
265
|
-
next.lock.heavy = nextLock(p.lock.heavy);
|
|
266
|
-
return { profile: next };
|
|
267
|
-
case "notify":
|
|
268
|
-
next.notify = NOTIFY.filter((m) => (m === r.moment ? !p.notify.includes(m) : p.notify.includes(m)));
|
|
269
|
-
return { profile: next };
|
|
270
|
-
case "backend":
|
|
271
|
-
return { profile: p };
|
|
272
|
-
case "budget":
|
|
273
|
-
case "failover":
|
|
274
|
-
case "budget-summary":
|
|
275
|
-
case "failover-summary":
|
|
276
|
-
// budget/failover open their own editor/picker in the Matrix component instead of toggling.
|
|
277
|
-
return { profile: p };
|
|
278
|
-
}
|
|
279
|
-
}
|