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.
Files changed (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. 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
- }
@@ -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
- }