habithive-code 0.2.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.
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: karpathy-guidelines
3
+ description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
4
+ license: MIT
5
+ ---
6
+
7
+ <!--
8
+ Bundled by habithive-code, unmodified, under the MIT License.
9
+ Source: andrej-karpathy-skills by forrestchang
10
+ https://github.com/forrestchang/andrej-karpathy-skills
11
+ Derived from Andrej Karpathy's observations on LLM coding pitfalls.
12
+ See habithive-code THIRD-PARTY-NOTICES.md for the full license text.
13
+ -->
14
+
15
+ # Karpathy Guidelines
16
+
17
+ Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
18
+
19
+ **Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
20
+
21
+ ## 1. Think Before Coding
22
+
23
+ **Don't assume. Don't hide confusion. Surface tradeoffs.**
24
+
25
+ Before implementing:
26
+ - State your assumptions explicitly. If uncertain, ask.
27
+ - If multiple interpretations exist, present them - don't pick silently.
28
+ - If a simpler approach exists, say so. Push back when warranted.
29
+ - If something is unclear, stop. Name what's confusing. Ask.
30
+
31
+ ## 2. Simplicity First
32
+
33
+ **Minimum code that solves the problem. Nothing speculative.**
34
+
35
+ - No features beyond what was asked.
36
+ - No abstractions for single-use code.
37
+ - No "flexibility" or "configurability" that wasn't requested.
38
+ - No error handling for impossible scenarios.
39
+ - If you write 200 lines and it could be 50, rewrite it.
40
+
41
+ Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
42
+
43
+ ## 3. Surgical Changes
44
+
45
+ **Touch only what you must. Clean up only your own mess.**
46
+
47
+ When editing existing code:
48
+ - Don't "improve" adjacent code, comments, or formatting.
49
+ - Don't refactor things that aren't broken.
50
+ - Match existing style, even if you'd do it differently.
51
+ - If you notice unrelated dead code, mention it - don't delete it.
52
+
53
+ When your changes create orphans:
54
+ - Remove imports/variables/functions that YOUR changes made unused.
55
+ - Don't remove pre-existing dead code unless asked.
56
+
57
+ The test: Every changed line should trace directly to the user's request.
58
+
59
+ ## 4. Goal-Driven Execution
60
+
61
+ **Define success criteria. Loop until verified.**
62
+
63
+ Transform tasks into verifiable goals:
64
+ - "Add validation" → "Write tests for invalid inputs, then make them pass"
65
+ - "Fix the bug" → "Write a test that reproduces it, then make it pass"
66
+ - "Refactor X" → "Ensure tests pass before and after"
67
+
68
+ For multi-step tasks, state a brief plan:
69
+ ```
70
+ 1. [Step] → verify: [check]
71
+ 2. [Step] → verify: [check]
72
+ 3. [Step] → verify: [check]
73
+ ```
74
+
75
+ Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
package/src/config.js ADDED
@@ -0,0 +1,97 @@
1
+ // habithive-code — opencode config wiring + mode switching.
2
+ //
3
+ // Surgically toggles oh-my-opencode in the opencode config while ALWAYS keeping
4
+ // the habithive plugin loaded, so /habithive-mode works in both modes:
5
+ // complex: oh-my-opencode ON (opencode.jsonc + tui.json) + habithive (tui.json)
6
+ // simple : oh-my-opencode OFF + habithive (tui.json)
7
+ // Preserves every other plugin/key. Atomic writes. Idempotent.
8
+ //
9
+ // We DEPEND on oh-my-opencode via npm (never bundle its code) and pin a tested
10
+ // version. Bump OMO_SPEC in a new habithive-code release after re-testing.
11
+
12
+ import { readFileSync, writeFileSync, existsSync, renameSync, mkdirSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ export const OMO_SPEC = "oh-my-openagent@4.19.1"; // pinned, tested version
18
+ export const CONFIG_DIR = join(homedir(), ".config", "opencode");
19
+ const OPENCODE_JSONC = join(CONFIG_DIR, "opencode.jsonc");
20
+ const OPENCODE_JSON = join(CONFIG_DIR, "opencode.json");
21
+ const TUI_JSON = join(CONFIG_DIR, "tui.json");
22
+
23
+ // file:// URL to this package's plugin entry (works when installed globally or local).
24
+ export const HABITHIVE_PLUGIN_URL = new URL("./plugin.js", import.meta.url).href;
25
+
26
+ const isOmo = (e) => typeof e === "string" && /oh-my-open(agent|code)/.test(e);
27
+ const isHabithive = (e) => typeof e === "string" && e.includes("habithive");
28
+
29
+ function readJson(path) {
30
+ try {
31
+ const raw = readFileSync(path, "utf8");
32
+ return JSON.parse(raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""));
33
+ } catch { return null; }
34
+ }
35
+ function writeAtomic(path, obj) {
36
+ mkdirSync(CONFIG_DIR, { recursive: true });
37
+ const tmp = `${path}.tmp-${process.pid}`;
38
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
39
+ renameSync(tmp, path);
40
+ }
41
+ const dropOmo = (arr) => (arr || []).filter((e) => !isOmo(e));
42
+ const dropHabithive = (arr) => (arr || []).filter((e) => !isHabithive(e));
43
+ const ensure = (arr, v) => (arr.includes(v) ? arr : [...arr, v]);
44
+
45
+ // mode: "simple" | "complex". Idempotent. Returns a short summary.
46
+ export function setMode(mode) {
47
+ const complex = mode === "complex";
48
+
49
+ // server plugins (opencode.jsonc): toggle oh-my-opencode only (habithive is TUI-only)
50
+ {
51
+ const c = readJson(OPENCODE_JSONC) || { "$schema": "https://opencode.ai/config.json" };
52
+ let arr = dropOmo(Array.isArray(c.plugin) ? c.plugin : []);
53
+ if (complex) arr = ensure(arr, OMO_SPEC);
54
+ c.plugin = arr;
55
+ writeAtomic(OPENCODE_JSONC, c);
56
+ }
57
+ // safety: keep opencode.json in sync only if it already declares plugins
58
+ if (existsSync(OPENCODE_JSON)) {
59
+ const c = readJson(OPENCODE_JSON);
60
+ if (c && Array.isArray(c.plugin)) {
61
+ let arr = dropOmo(c.plugin);
62
+ if (complex) arr = ensure(arr, OMO_SPEC);
63
+ c.plugin = arr;
64
+ writeAtomic(OPENCODE_JSON, c);
65
+ }
66
+ }
67
+ // tui plugins (tui.json): ALWAYS keep habithive; toggle oh-my-opencode
68
+ {
69
+ const c = readJson(TUI_JSON) || {};
70
+ let arr = dropHabithive(dropOmo(Array.isArray(c.plugin) ? c.plugin : []));
71
+ arr = ensure(arr, HABITHIVE_PLUGIN_URL);
72
+ if (complex) arr = ensure(arr, OMO_SPEC);
73
+ c.plugin = arr;
74
+ writeAtomic(TUI_JSON, c);
75
+ }
76
+ return `config set to ${mode} mode`;
77
+ }
78
+
79
+ // Add an MCP server to the opencode config if not already present (in either
80
+ // opencode.jsonc or opencode.json). Non-destructive; preserves other mcp entries.
81
+ export function ensureMcp(name, spec) {
82
+ const inJson = (() => { const c = readJson(OPENCODE_JSON); return c && c.mcp && c.mcp[name]; })();
83
+ const c = readJson(OPENCODE_JSONC) || { "$schema": "https://opencode.ai/config.json" };
84
+ if ((c.mcp && c.mcp[name]) || inJson) return false; // already configured somewhere
85
+ c.mcp = { ...(c.mcp || {}), [name]: spec };
86
+ writeAtomic(OPENCODE_JSONC, c);
87
+ return true;
88
+ }
89
+
90
+ // Is oh-my-opencode currently wired (i.e., are we in complex on disk)?
91
+ export function currentMode() {
92
+ const c = readJson(OPENCODE_JSONC);
93
+ return c && Array.isArray(c.plugin) && c.plugin.some(isOmo) ? "complex" : "simple";
94
+ }
95
+
96
+ export const PATHS = { OPENCODE_JSONC, OPENCODE_JSON, TUI_JSON, CONFIG_DIR };
97
+ export { fileURLToPath };
package/src/core.js ADDED
@@ -0,0 +1,242 @@
1
+ // habithive-code — core engine (pure Node/Bun ESM, no opencode deps).
2
+ //
3
+ // Owns:
4
+ // 1. The model-tree store + resolver, compiled into oh-my-opencode's
5
+ // oh-my-openagent.json (subagent/category models). Applied on next launch.
6
+ // 2. The on-screen (primary agent) model = opencode's picker state model.json.
7
+ // 3. The mode marker used to hand an in-app simple/complex switch to the CLI.
8
+ //
9
+ // Model resolution tree (most specific wins):
10
+ // agent override > category override > "all" override > baseline
11
+
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, copyFileSync, rmSync } from "node:fs";
15
+
16
+ const HOME = homedir();
17
+ export const CONFIG_DIR = join(HOME, ".config", "opencode");
18
+ export const OMO_CONFIG = join(CONFIG_DIR, "oh-my-openagent.json");
19
+ export const STORE_DIR = join(HOME, ".habithive");
20
+ export const STORE_FILE = join(STORE_DIR, "overrides.json");
21
+ export const BASELINE_FILE = join(STORE_DIR, "baseline.json");
22
+ export const OMO_BACKUP = join(STORE_DIR, "oh-my-openagent.first-backup.json");
23
+ export const MODE_MARKER = join(STORE_DIR, "next-mode");
24
+ export const SESSION_MARKER = join(STORE_DIR, "next-session");
25
+ // opencode's native model-picker state — recent[0] is the current on-screen model.
26
+ export const MODEL_STATE = join(HOME, ".local", "state", "opencode", "model.json");
27
+
28
+ // Authoritative from oh-my-opencode's BuiltinCategoryNameSchema — 8 tiers.
29
+ export const KNOWN_CATEGORIES = [
30
+ "ultrabrain", "deep", "quick", "visual-engineering",
31
+ "artistry", "writing", "unspecified-low", "unspecified-high",
32
+ ];
33
+
34
+ // Authoritative agent registry (BuiltinAgentNameSchema + live /agent modes).
35
+ // MAIN = primary mode (Tab-between; use the on-screen/UI-selected model).
36
+ // SUB = subagent mode (spawned via task; own model from oh-my-openagent.json).
37
+ export const MAIN_AGENTS = [
38
+ { key: "sisyphus", label: "Sisyphus — ultraworker" },
39
+ { key: "prometheus", label: "Prometheus — plan builder" },
40
+ { key: "atlas", label: "Atlas — plan executor" },
41
+ { key: "hephaestus", label: "Hephaestus — deep (GPT-only)" },
42
+ ];
43
+ export const SUB_AGENTS = [
44
+ { key: "oracle", label: "Oracle — deep reasoning / debugging" },
45
+ { key: "explore", label: "Explore — codebase search" },
46
+ { key: "librarian", label: "Librarian — external docs" },
47
+ { key: "multimodal-looker", label: "Multimodal-looker — images / visual" },
48
+ { key: "metis", label: "Metis — plan consultant" },
49
+ { key: "momus", label: "Momus — plan critic" },
50
+ { key: "sisyphus-junior", label: "Sisyphus-Junior — worker" },
51
+ ];
52
+ export const MAIN_KEYS = MAIN_AGENTS.map((a) => a.key);
53
+ export const ALL_AGENTS = [...MAIN_AGENTS, ...SUB_AGENTS].map((a) => a.key);
54
+ export function isMainAgent(key) { return MAIN_KEYS.includes(key); }
55
+
56
+ export function defaultStore() {
57
+ return { version: 1, modelOverrides: { all: null, categories: {}, agents: {} } };
58
+ }
59
+
60
+ function readJson(path, fallback) {
61
+ try {
62
+ if (!existsSync(path)) return fallback;
63
+ const raw = readFileSync(path, "utf8");
64
+ const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
65
+ return JSON.parse(stripped);
66
+ } catch { return fallback; }
67
+ }
68
+
69
+ function writeJsonAtomic(path, obj) {
70
+ mkdirSync(STORE_DIR, { recursive: true });
71
+ const tmp = `${path}.tmp-${process.pid}-${globalThis.performance ? Math.floor(performance.now()) : "t"}`;
72
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
73
+ renameSync(tmp, path);
74
+ }
75
+
76
+ export function loadStore() {
77
+ const s = readJson(STORE_FILE, null);
78
+ if (!s) return defaultStore();
79
+ return {
80
+ version: s.version || 1,
81
+ modelOverrides: {
82
+ all: s.modelOverrides?.all ?? null,
83
+ categories: { ...(s.modelOverrides?.categories || {}) },
84
+ agents: { ...(s.modelOverrides?.agents || {}) },
85
+ },
86
+ };
87
+ }
88
+
89
+ export function saveStore(store) { writeJsonAtomic(STORE_FILE, store); }
90
+
91
+ export function ensureBaseline() {
92
+ mkdirSync(STORE_DIR, { recursive: true });
93
+ if (!existsSync(BASELINE_FILE)) {
94
+ const current = readJson(OMO_CONFIG, {});
95
+ writeJsonAtomic(BASELINE_FILE, current);
96
+ try { if (existsSync(OMO_CONFIG)) copyFileSync(OMO_CONFIG, OMO_BACKUP); } catch {}
97
+ }
98
+ return readJson(BASELINE_FILE, {});
99
+ }
100
+
101
+ export function loadBaseline() { return readJson(BASELINE_FILE, {}); }
102
+
103
+ // ---- resolution ----------------------------------------------------------
104
+
105
+ export function resolveCategoryModel(store, baseline, cat) {
106
+ return (
107
+ store.modelOverrides.categories[cat] ??
108
+ store.modelOverrides.all ??
109
+ baseline?.categories?.[cat]?.model ??
110
+ null
111
+ );
112
+ }
113
+
114
+ function hasAnyCategoryOverride(store) {
115
+ return Object.keys(store.modelOverrides.categories || {}).length > 0;
116
+ }
117
+
118
+ // Compile store + baseline -> oh-my-openagent.json content. Non-destructive.
119
+ export function compileOmoConfig(store, baseline) {
120
+ const out = structuredClone(baseline || {});
121
+ out.categories = { ...(out.categories || {}) };
122
+ out.agents = { ...(out.agents || {}) };
123
+
124
+ for (const c of new Set([...KNOWN_CATEGORIES, ...Object.keys(out.categories)])) {
125
+ const model = resolveCategoryModel(store, baseline, c);
126
+ if (model) out.categories[c] = { ...(out.categories[c] || {}), model };
127
+ }
128
+
129
+ const hasCat = hasAnyCategoryOverride(store);
130
+ const hasAll = store.modelOverrides.all != null;
131
+ for (const a of new Set([...ALL_AGENTS, ...Object.keys(out.agents)])) {
132
+ const leaf = store.modelOverrides.agents[a];
133
+ if (leaf) {
134
+ out.agents[a] = { ...(out.agents[a] || {}), model: leaf };
135
+ } else if (hasCat) {
136
+ // category overrides active: drop the agent model, let oh-my-opencode cascade from its category
137
+ if (out.agents[a] && "model" in out.agents[a]) {
138
+ const { model, ...rest } = out.agents[a];
139
+ if (Object.keys(rest).length) out.agents[a] = rest; else delete out.agents[a];
140
+ }
141
+ } else if (hasAll) {
142
+ out.agents[a] = { ...(out.agents[a] || {}), model: store.modelOverrides.all };
143
+ } else {
144
+ const base = baseline?.agents?.[a];
145
+ if (base) out.agents[a] = structuredClone(base); else delete out.agents[a];
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
151
+ export function applyStore(store) {
152
+ try {
153
+ const baseline = ensureBaseline();
154
+ if (!baseline || !baseline.agents || Object.keys(baseline.agents).length === 0) {
155
+ return { ok: false, error: "baseline not captured (oh-my-openagent.json empty/missing); refusing to write" };
156
+ }
157
+ writeJsonAtomic(OMO_CONFIG, compileOmoConfig(store, baseline));
158
+ return { ok: true };
159
+ } catch (e) {
160
+ return { ok: false, error: String(e?.message || e) };
161
+ }
162
+ }
163
+
164
+ // ---- mutations -----------------------------------------------------------
165
+
166
+ export function setModelOverride(store, level, target, modelId) {
167
+ if (level === "all") store.modelOverrides.all = modelId || null;
168
+ else if (level === "category") store.modelOverrides.categories[target] = modelId;
169
+ else if (level === "agent") store.modelOverrides.agents[target] = modelId;
170
+ return store;
171
+ }
172
+
173
+ export function clearModelOverride(store, level, target) {
174
+ if (level === "all") store.modelOverrides.all = null;
175
+ else if (level === "category") delete store.modelOverrides.categories[target];
176
+ else if (level === "agent") delete store.modelOverrides.agents[target];
177
+ return store;
178
+ }
179
+
180
+ export function effectiveModel(store, baseline, level, target) {
181
+ if (level === "all") return store.modelOverrides.all ?? null;
182
+ if (level === "category") return resolveCategoryModel(store, baseline, target);
183
+ if (level === "agent") return store.modelOverrides.agents[target] ?? baseline?.agents?.[target]?.model ?? null;
184
+ return null;
185
+ }
186
+
187
+ // ---- on-screen (primary) model = model.json recent[0] --------------------
188
+
189
+ function splitModelId(idStr) {
190
+ const i = String(idStr).indexOf("/");
191
+ if (i < 0) return null;
192
+ return { providerID: idStr.slice(0, i), modelID: idStr.slice(i + 1) };
193
+ }
194
+
195
+ export function currentMainModel() {
196
+ try {
197
+ const s = JSON.parse(readFileSync(MODEL_STATE, "utf8"));
198
+ const m = s?.recent?.[0];
199
+ return m ? `${m.providerID}/${m.modelID}` : null;
200
+ } catch { return null; }
201
+ }
202
+
203
+ export function setMainModel(idStr) {
204
+ try {
205
+ const parts = splitModelId(idStr);
206
+ if (!parts) return { ok: false, error: "bad model id (expected provider/model)" };
207
+ let state = {};
208
+ try { state = JSON.parse(readFileSync(MODEL_STATE, "utf8")); } catch {}
209
+ if (!Array.isArray(state.recent)) state.recent = [];
210
+ state.recent = state.recent.filter((m) => !(m.providerID === parts.providerID && m.modelID === parts.modelID));
211
+ state.recent.unshift(parts);
212
+ mkdirSync(join(HOME, ".local", "state", "opencode"), { recursive: true });
213
+ const tmp = `${MODEL_STATE}.tmp-${process.pid}`;
214
+ writeFileSync(tmp, JSON.stringify(state), "utf8");
215
+ renameSync(tmp, MODEL_STATE);
216
+ return { ok: true };
217
+ } catch (e) {
218
+ return { ok: false, error: String(e?.message || e) };
219
+ }
220
+ }
221
+
222
+ // ---- mode marker (in-app switch handshake with the `habithive` launcher) ------
223
+
224
+ export function writeNextMode(mode, session) {
225
+ mkdirSync(STORE_DIR, { recursive: true });
226
+ writeFileSync(MODE_MARKER, `${mode}\n`, "utf8");
227
+ // Record the exact session to reopen so the relaunch returns HERE. A new/empty
228
+ // session has no id yet → no marker → the launcher opens a fresh session
229
+ // (never --continue, which would reopen the last saved conversation).
230
+ if (session) writeFileSync(SESSION_MARKER, `${session}\n`, "utf8");
231
+ else { try { rmSync(SESSION_MARKER, { force: true }); } catch {} }
232
+ }
233
+ export function readNextMode() {
234
+ try { return existsSync(MODE_MARKER) ? readFileSync(MODE_MARKER, "utf8").trim() : null; } catch { return null; }
235
+ }
236
+ export function readNextSession() {
237
+ try { return existsSync(SESSION_MARKER) ? readFileSync(SESSION_MARKER, "utf8").trim() : null; } catch { return null; }
238
+ }
239
+ export function clearNextMode() {
240
+ try { rmSync(MODE_MARKER, { force: true }); } catch {}
241
+ try { rmSync(SESSION_MARKER, { force: true }); } catch {}
242
+ }
package/src/logo.js ADDED
@@ -0,0 +1,37 @@
1
+ // habithive-code — home-screen wordmark in opencode's TUI.
2
+ //
3
+ // Renders a big <ascii_font> "HABITHIVE" (block font, clean outline) into the
4
+ // `home_logo` slot. A @opentui/core renderable is a valid slot node, so there's
5
+ // no JSX build step. We probe-construct once at register time (inside try/catch,
6
+ // OUTSIDE opencode's render loop) so any failure aborts gracefully instead of
7
+ // crashing the home screen — the slot is only registered if the probe succeeds.
8
+
9
+ const TEXT = "habithive"; // ascii_font uppercases it
10
+ const FONT = "block"; // tiny | slick | block | huge | shade | grid | pallet
11
+ const COLOR = "#7c3aed"; // violet
12
+
13
+ export async function registerLogo(api) {
14
+ try {
15
+ if (!api || !api.slots || typeof api.slots.register !== "function") return;
16
+ const rnd = api.renderer;
17
+ let rctx = null;
18
+ try { rctx = (rnd && rnd.renderContext) || (rnd && rnd.root && rnd.root.ctx) || null; } catch {}
19
+ if (!rctx) return;
20
+
21
+ let AsciiFont;
22
+ try { AsciiFont = (await import("@opentui/core")).ASCIIFontRenderable; } catch { return; }
23
+ if (typeof AsciiFont !== "function") return;
24
+
25
+ const make = () => new AsciiFont(rctx, { text: TEXT, font: FONT, color: COLOR });
26
+
27
+ // Prove construction works before registering (a throw here is caught → no crash).
28
+ try {
29
+ const probe = make();
30
+ if (probe && typeof probe.destroyRecursively === "function") probe.destroyRecursively();
31
+ } catch { return; }
32
+
33
+ api.slots.register({ slots: { home_logo: () => make() } });
34
+ } catch {
35
+ // never break the home screen
36
+ }
37
+ }
package/src/plugin.js ADDED
@@ -0,0 +1,217 @@
1
+ // habithive-code — opencode TUI plugin. Loaded in complex mode (oh-my-opencode on).
2
+ // Plain ESM, no JSX: host UI components are plain functions from api.ui.*, so
3
+ // dialogs are nested calls. Everything is wrapped so it can never throw at init.
4
+ //
5
+ // Commands:
6
+ // /habithive umbrella menu (set model / switch mode / reset)
7
+ // /model nested model tree: level (main / all / group / agent / category) -> model
8
+ // /habithive-mode switch complex <-> simple: queues a marker + quits (app.exit);
9
+ // the `habithive` launcher relaunches in the queued mode, same session.
10
+
11
+ import {
12
+ loadStore, saveStore, applyStore, loadBaseline, ensureBaseline,
13
+ setModelOverride, clearModelOverride, effectiveModel, writeNextMode,
14
+ setMainModel, currentMainModel, isMainAgent,
15
+ KNOWN_CATEGORIES, MAIN_AGENTS, SUB_AGENTS,
16
+ } from "./core.js";
17
+ import { registerLogo } from "./logo.js";
18
+
19
+ function listModels(api) {
20
+ const out = [];
21
+ const providers = (api && api.state && api.state.provider) || [];
22
+ for (const p of providers) {
23
+ const pid = p?.id || p?.providerID || p?.name;
24
+ if (!pid) continue;
25
+ const models = p.models;
26
+ const entries = Array.isArray(models) ? models.map((m) => [m?.id || m?.modelID, m]) : Object.entries(models || {});
27
+ for (const [mid, m] of entries) {
28
+ if (!mid) continue;
29
+ out.push({ id: `${pid}/${mid}`, providerID: pid, label: `${pid}/${mid}`, name: (m && (m.name || m.id)) || mid });
30
+ }
31
+ }
32
+ out.sort((a, b) => a.id.localeCompare(b.id));
33
+ return out;
34
+ }
35
+
36
+ function toast(api, message, variant = "info") {
37
+ try { api.ui.toast({ message, variant }); } catch {}
38
+ }
39
+
40
+ // Queue a mode switch and quit; the launcher relaunches (same session, --continue).
41
+ async function switchMode(api, mode) {
42
+ // Only target a REAL session (one with messages). A brand-new/empty session has
43
+ // no persisted id yet, so we leave it null and the launcher opens a fresh session.
44
+ let sessionID = null;
45
+ try {
46
+ const cur = api?.route?.current;
47
+ if (cur && cur.name === "session" && cur.params && cur.params.sessionID) {
48
+ const id = cur.params.sessionID;
49
+ const msgs = api?.state?.session?.messages?.(id);
50
+ if (msgs && msgs.length > 0) sessionID = id;
51
+ }
52
+ } catch {}
53
+ try { writeNextMode(mode, sessionID); }
54
+ catch (e) { toast(api, `could not queue switch: ${String(e?.message || e)}`, "error"); return; }
55
+ toast(api, `Switching to ${mode.toUpperCase()} — reopening this session…`, "info");
56
+ let exited = false;
57
+ for (const cmd of ["app.exit", "app.quit", "quit", "exit"]) {
58
+ try { await api.keymap.dispatchCommand(cmd); exited = true; break; } catch {}
59
+ }
60
+ if (!exited) toast(api, `Quit opencode — it relaunches in ${mode} mode (habithive launcher).`, "warning");
61
+ }
62
+
63
+ // ---- model tree ----------------------------------------------------------
64
+
65
+ function openModelPicker(api, level, target) {
66
+ const models = listModels(api);
67
+ const current = level === "main" ? currentMainModel()
68
+ : (level === "agent" || level === "category") ? effectiveModel(loadStore(), loadBaseline(), level, target)
69
+ : null;
70
+ const label = level === "main" ? "MAIN agent (this screen)"
71
+ : level === "all" ? "ALL (on-screen + subagents)"
72
+ : level === "main-group" ? "ALL main agents"
73
+ : level === "sub-group" ? "ALL sub agents"
74
+ : `${level}: ${target}`;
75
+
76
+ const options = [
77
+ { title: "↺ Clear override (use default)", value: { clear: true }, category: "Actions" },
78
+ ...models.map((m) => ({
79
+ title: m.id + (m.id === current ? " ·current" : ""),
80
+ value: { id: m.id }, description: m.name, category: m.providerID,
81
+ })),
82
+ ];
83
+
84
+ api.ui.dialog.replace(() =>
85
+ api.ui.DialogSelect({
86
+ title: `habithive · set model for ${label}`,
87
+ placeholder: "filter models…",
88
+ options, current,
89
+ onSelect: async (opt) => {
90
+ const clear = !!opt.value.clear;
91
+ const id = opt.value.id;
92
+
93
+ if (level === "main") {
94
+ if (clear) { toast(api, "main agent: pick a model (nothing to clear)", "warning"); return; }
95
+ const r = setMainModel(id);
96
+ api.ui.dialog.clear();
97
+ if (!r.ok) { toast(api, `main set failed: ${r.error}`, "error"); return; }
98
+ toast(api, `${label} → ${id} · applying (reopening session)…`, "info");
99
+ await switchMode(api, "complex");
100
+ return;
101
+ }
102
+
103
+ const s = loadStore();
104
+ const setKeys = (keys) => keys.forEach((k) => clear ? clearModelOverride(s, "agent", k) : setModelOverride(s, "agent", k, id));
105
+ let touchesMainScreen = false;
106
+
107
+ if (level === "main-group") { setKeys(MAIN_AGENTS.map((a) => a.key)); touchesMainScreen = true; }
108
+ else if (level === "sub-group") { setKeys(SUB_AGENTS.map((a) => a.key)); }
109
+ else if (level === "all") { clear ? clearModelOverride(s, "all") : setModelOverride(s, "all", null, id); touchesMainScreen = true; }
110
+ else {
111
+ clear ? clearModelOverride(s, level, target) : setModelOverride(s, level, target, id);
112
+ if (level === "agent" && isMainAgent(target)) touchesMainScreen = true;
113
+ }
114
+
115
+ saveStore(s);
116
+ const r = applyStore(s);
117
+ if (!clear && touchesMainScreen) setMainModel(id);
118
+ api.ui.dialog.clear();
119
+ if (!r.ok) { toast(api, `apply failed: ${r.error}`, "error"); return; }
120
+ toast(api, `${label} → ${clear ? "default" : id} · applying (reopening session)…`, "info");
121
+ await switchMode(api, "complex");
122
+ },
123
+ }),
124
+ );
125
+ }
126
+
127
+ function openLevelPicker(api) {
128
+ ensureBaseline();
129
+ const store = loadStore();
130
+ const baseline = loadBaseline();
131
+ const fmt = (lvl, tgt) => {
132
+ const m = lvl === "main" ? currentMainModel() : effectiveModel(store, baseline, lvl, tgt);
133
+ return m ? `→ ${m}` : "→ (default)";
134
+ };
135
+ const groupModel = (keys) => {
136
+ const uniq = [...new Set(keys.map((k) => effectiveModel(store, baseline, "agent", k)))];
137
+ return uniq.length === 1 ? (uniq[0] ? `→ all: ${uniq[0]}` : "→ (default)") : "→ (mixed)";
138
+ };
139
+ const options = [
140
+ { title: "Main agent — this screen", description: fmt("main"), value: { level: "main" }, category: "Quick" },
141
+ { title: "ALL — on-screen + every subagent + tier", description: fmt("all"), value: { level: "all" }, category: "Quick" },
142
+
143
+ { title: "★ All main agents (set together)", description: groupModel(MAIN_AGENTS.map((a) => a.key)), value: { level: "main-group" }, category: "Main agents (primary)" },
144
+ ...MAIN_AGENTS.map((a) => ({ title: a.label, description: fmt("agent", a.key), value: { level: "agent", target: a.key }, category: "Main agents (primary)" })),
145
+
146
+ { title: "★ All sub agents (set together)", description: groupModel(SUB_AGENTS.map((a) => a.key)), value: { level: "sub-group" }, category: "Subagents" },
147
+ ...SUB_AGENTS.map((a) => ({ title: a.label, description: fmt("agent", a.key), value: { level: "agent", target: a.key }, category: "Subagents" })),
148
+
149
+ ...KNOWN_CATEGORIES.map((c) => ({ title: c, description: fmt("category", c), value: { level: "category", target: c }, category: "Categories (tiers)" })),
150
+ ];
151
+ api.ui.dialog.replace(() =>
152
+ api.ui.DialogSelect({
153
+ title: "habithive · set model — pick a level (most-specific wins)",
154
+ placeholder: "filter…", options,
155
+ onSelect: (opt) => openModelPicker(api, opt.value.level, opt.value.target),
156
+ }),
157
+ );
158
+ }
159
+
160
+ function openModeMenu(api) {
161
+ api.ui.dialog.replace(() =>
162
+ api.ui.DialogSelect({
163
+ title: "habithive · mode (you are in COMPLEX mode)",
164
+ options: [
165
+ { title: "→ Simple mode (vanilla opencode, fast)", value: "simple", description: "quits & relaunches vanilla, same session" },
166
+ { title: "→ Complex mode (relaunch to apply model changes)", value: "complex", description: "quits & relaunches full, same session" },
167
+ ],
168
+ onSelect: (opt) => { api.ui.dialog.clear(); switchMode(api, opt.value); },
169
+ }),
170
+ );
171
+ }
172
+
173
+ function openUmbrella(api) {
174
+ api.ui.dialog.replace(() =>
175
+ api.ui.DialogSelect({
176
+ title: "habithive-code",
177
+ options: [
178
+ { title: "Set model (main / all / groups / agent / tier)", value: "model" },
179
+ { title: "Switch mode (simple / complex)", value: "mode" },
180
+ { title: "Reset all model overrides", value: "reset" },
181
+ ],
182
+ onSelect: (opt) => {
183
+ if (opt.value === "model") openLevelPicker(api);
184
+ else if (opt.value === "mode") openModeMenu(api);
185
+ else if (opt.value === "reset") {
186
+ const s = loadStore();
187
+ s.modelOverrides = { all: null, categories: {}, agents: {} };
188
+ saveStore(s);
189
+ const r = applyStore(s);
190
+ api.ui.dialog.clear();
191
+ toast(api, r.ok ? "overrides cleared (baseline restored) · relaunch to apply" : `reset failed: ${r.error}`, r.ok ? "success" : "error");
192
+ }
193
+ },
194
+ }),
195
+ );
196
+ }
197
+
198
+ const HabithiveTui = async (api) => {
199
+ try {
200
+ const reg = api && api.command && api.command.register;
201
+ if (typeof reg !== "function") {
202
+ try { api.ui.toast({ message: "habithive-code: command API unavailable", variant: "warning" }); } catch {}
203
+ return;
204
+ }
205
+ reg(() => [
206
+ { title: "habithive-code", value: "habithive.menu", description: "model tree + mode switch", category: "habithive-code", slash: { name: "habithive", aliases: ["habithive-code"] }, onSelect: () => openUmbrella(api) },
207
+ { title: "habithive · set model", value: "habithive.model", description: "set model by level (main / all / groups / agent / tier)", category: "habithive-code", slash: { name: "model", aliases: ["habithivemodel"] }, onSelect: () => openLevelPicker(api) },
208
+ { title: "habithive · switch mode", value: "habithive.mode", description: "complex <-> simple (relaunch, same session)", category: "habithive-code", slash: { name: "habithive-mode", aliases: ["habithivemode"] }, onSelect: () => openModeMenu(api) },
209
+ ]);
210
+ registerLogo(api); // v2 — HABITHIVE wordmark in home_logo (self-guarded, test-constructed)
211
+ } catch (e) {
212
+ try { api.ui.toast({ message: `habithive-code init failed: ${String(e?.message || e)}`, variant: "error" }); } catch {}
213
+ }
214
+ };
215
+
216
+ export default { id: "habithive-code", tui: HabithiveTui };
217
+ export const HabithivePlugin = HabithiveTui;