habithive-code 0.2.3 → 0.3.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,119 @@
1
+ // habithive-code — the OmniRoute provider Hive ships (ADR-0007: Hive owns the shipped keys, the user owns
2
+ // the rest, Hive never writes secrets). Keys come from opencode's /connect; no model is ever made a default.
3
+ //
4
+ // ensureOmniroute(config): pure — resets name, adapter and the shipped model entries; keeps extra
5
+ // models and every other option (e.g. an old apiKey); touches nothing outside provider.omniroute.
6
+ // omnirouteTargets(): the one file to edit per existing mode set (highest-precedence file that already has
7
+ // the block, else that set's opencode.jsonc), for reconcile() to apply via safeEdit.
8
+
9
+ import { join, basename } from "node:path";
10
+ import { readConfig, safeEdit, ConfigParseError } from "./jsonc.js";
11
+ import { CONFIG_DIR, filesForMode } from "./config.js";
12
+
13
+ export const OMNIROUTE_ID = "omniroute";
14
+ // The package ships no gateway address: baseURL is a placeholder until `habithive --gateway <ip>` sets it.
15
+ export const PLACEHOLDER_BASE_URL = "http://localhost:20128/v1";
16
+ export const GATEWAY_PORT = 20128;
17
+ export const GATEWAY_SCHEME = "http";
18
+ export const OMNIROUTE_BASE = {
19
+ name: "OmniRoute",
20
+ npm: "@ai-sdk/openai-compatible",
21
+ options: { baseURL: PLACEHOLDER_BASE_URL },
22
+ };
23
+
24
+ // id, context (= input), output — the design appendix, in order
25
+ const ROWS = `auto/best-coding 1000000 384000
26
+ auto/best-reasoning 1000000 384000
27
+ auto/best-fast 1000000 384000
28
+ auto/best-vision 200000 131072
29
+ auto/best-chat 1000000 384000
30
+ auto/best-coding-fast 1000000 384000
31
+ auto/pro-coding 1000000 384000
32
+ auto/pro-reasoning 1000000 384000
33
+ auto/pro-vision 128000 8192
34
+ auto/pro-chat 1000000 384000
35
+ auto/pro-fast 1000000 384000
36
+ auto/coding 1000000 384000
37
+ auto/fast 1000000 384000
38
+ auto/chat 1000000 384000
39
+ auto/cheap 1000000 384000
40
+ auto/offline 1000000 384000
41
+ auto/smart 1000000 384000
42
+ auto/claude-opus 1000000 384000
43
+ auto/claude-sonnet 1000000 384000
44
+ auto/best-free 1000000 384000
45
+ auto/best-chaos 1000000 384000
46
+ auto/chaos 1000000 384000
47
+ auto/coding:fast 1000000 384000
48
+ auto/coding:cheap 1000000 384000
49
+ auto/coding:free 1000000 384000
50
+ auto/coding:pro 128000 8192
51
+ auto/coding:reliable 1000000 384000
52
+ auto/reasoning 1000000 384000
53
+ auto/reasoning:pro 128000 8192
54
+ auto/vision 200000 131072
55
+ auto/multimodal 200000 131072
56
+ auto/glm 1000000 131072
57
+ auto/minimax 128000 8192
58
+ auto/mimo 200000 131072
59
+ auto/zai 128000 8192
60
+ auto/gemma 128000 8192
61
+ auto/llama 128000 8192
62
+ auto/gemini 1048576 65535`;
63
+
64
+ export const OMNIROUTE_MODELS = Object.fromEntries(ROWS.split("\n").map((l) => {
65
+ const [id, context, output] = l.split(" ");
66
+ return [id, { name: id, reasoning: true, temperature: true, tool_call: true, limit: { context: +context, output: +output, input: +context } }];
67
+ }));
68
+
69
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
70
+
71
+ export function ensureOmniroute(config) {
72
+ const out = structuredClone(config ?? {});
73
+ if (!isObj(out.provider)) out.provider = {};
74
+ const cur = isObj(out.provider[OMNIROUTE_ID]) ? out.provider[OMNIROUTE_ID] : (out.provider[OMNIROUTE_ID] = {});
75
+ const { options, ...own } = structuredClone(OMNIROUTE_BASE);
76
+ Object.assign(cur, own);
77
+ cur.options = { ...(isObj(cur.options) ? cur.options : {}) };
78
+ if (typeof cur.options.baseURL !== "string" || !cur.options.baseURL) cur.options.baseURL = options.baseURL;
79
+ cur.models = { ...(isObj(cur.models) ? cur.models : {}), ...structuredClone(OMNIROUTE_MODELS) };
80
+ return out;
81
+ }
82
+
83
+ const hasBlock = (f) => {
84
+ const r = readConfig(f);
85
+ if (!r.ok) throw new ConfigParseError(r.file, r.line, r.column, r.message);
86
+ return r.value?.provider?.[OMNIROUTE_ID] != null;
87
+ };
88
+
89
+ // De-duplicated absolute paths, one per existing mode set (complex, simple). Throws ConfigParseError on an unreadable file.
90
+ export function omnirouteTargets() {
91
+ const out = new Set();
92
+ for (const mode of ["complex", "simple"]) {
93
+ const files = filesForMode(mode);
94
+ if (!files) continue;
95
+ const named = (n) => files.find((f) => basename(f) === n);
96
+ const jsonc = named("opencode.jsonc");
97
+ const cands = [jsonc, named("opencode.json") ?? join(CONFIG_DIR, "opencode.json"), join(CONFIG_DIR, "config.json")];
98
+ out.add(cands.find(hasBlock) ?? jsonc);
99
+ }
100
+ return [...out];
101
+ }
102
+
103
+ // Validate a bare dotted-quad IPv4 (no scheme/port) and build the gateway base URL.
104
+ export function gatewayURL(input) {
105
+ const ip = String(input ?? "").trim();
106
+ if (ip.includes(":")) return { ok: false, error: "pass only the IP address: habithive --gateway <ip>" };
107
+ const p = ip.split(".");
108
+ if (p.length !== 4 || !p.every((x) => /^(0|[1-9]\d{0,2})$/.test(x) && +x <= 255)) return { ok: false, error: `not a valid IPv4 address: ${input}` };
109
+ return { ok: true, url: `${GATEWAY_SCHEME}://${ip}:${GATEWAY_PORT}/v1` };
110
+ }
111
+
112
+ // Write the gateway URL into every mode set's OmniRoute block (adding the block if missing). Returns changed paths.
113
+ export function setGateway(url) {
114
+ return { files: omnirouteTargets().filter((t) => safeEdit(t, (c) => {
115
+ const out = ensureOmniroute(c);
116
+ out.provider[OMNIROUTE_ID].options.baseURL = url;
117
+ return out;
118
+ }).changed) };
119
+ }
@@ -0,0 +1,87 @@
1
+ // habithive-code — ships the intent-driven OpenSpec schema into OpenSpec's user-level data dir.
2
+ // openspecDataDir mirrors OpenSpec 1.5.0 getGlobalDataDir() exactly.
3
+
4
+ import { homedir as osHomedir } from "node:os";
5
+ import { posix, win32, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, renameSync, rmSync, statSync } from "node:fs";
8
+ import { createHash } from "node:crypto";
9
+ import { STORE_DIR } from "./core.js";
10
+
11
+ export const SCHEMA_NAME = "intent-driven";
12
+ export const SHIPPED_SCHEMA_DIR = fileURLToPath(new URL("../openspec-schemas/intent-driven", import.meta.url));
13
+ // sha256 of the schema tree Hive last installed (tells our copy from the user's edits)
14
+ const INSTALLED_HASH = join(STORE_DIR, "schema-installed.sha256");
15
+
16
+ export function openspecDataDir({ env = process.env, platform = process.platform, homedir = osHomedir() } = {}) {
17
+ const j = platform === "win32" ? win32.join : posix.join;
18
+ if (env.XDG_DATA_HOME) return j(env.XDG_DATA_HOME, "openspec");
19
+ if (platform === "win32") {
20
+ if (env.LOCALAPPDATA) return j(env.LOCALAPPDATA, "openspec");
21
+ return j(homedir, "AppData", "Local", "openspec");
22
+ }
23
+ return j(homedir, ".local", "share", "openspec");
24
+ }
25
+
26
+ export function userSchemaDir(opts) {
27
+ const o = opts || {};
28
+ const j = (o.platform || process.platform) === "win32" ? win32.join : posix.join;
29
+ return j(openspecDataDir(o), "schemas", SCHEMA_NAME);
30
+ }
31
+
32
+ function files(dir, rel = "") {
33
+ const out = [];
34
+ for (const n of readdirSync(join(dir, rel)).sort()) {
35
+ const r = rel ? `${rel}/${n}` : n;
36
+ if (statSync(join(dir, r)).isDirectory()) out.push(...files(dir, r));
37
+ else out.push(r);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ export function treeHash(dir) {
43
+ if (!existsSync(dir)) return null;
44
+ const h = createHash("sha256");
45
+ for (const r of files(dir)) {
46
+ h.update(r + "\0");
47
+ h.update(readFileSync(join(dir, r)));
48
+ h.update("\0");
49
+ }
50
+ return h.digest("hex");
51
+ }
52
+
53
+ export function copyTree(src, dst) {
54
+ mkdirSync(dst, { recursive: true });
55
+ for (const n of readdirSync(src)) {
56
+ const s = join(src, n), d = join(dst, n);
57
+ if (statSync(s).isDirectory()) copyTree(s, d);
58
+ else copyFileSync(s, d);
59
+ }
60
+ }
61
+
62
+ const stamp = (d) => d.toISOString().replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
63
+
64
+ export function installSchema({ now = new Date(), env, platform, homedir } = {}) {
65
+ const path = userSchemaDir({ env, platform, homedir });
66
+ const want = treeHash(SHIPPED_SCHEMA_DIR);
67
+ if (!want) throw new Error(`shipped schema missing: ${SHIPPED_SCHEMA_DIR}`);
68
+ const have = treeHash(path);
69
+ if (have === want) return { changed: false, path };
70
+ const tmp = `${path}.tmp-${process.pid}`;
71
+ rmSync(tmp, { recursive: true, force: true });
72
+ copyTree(SHIPPED_SCHEMA_DIR, tmp);
73
+ let backup;
74
+ if (have !== null) {
75
+ const last = existsSync(INSTALLED_HASH) ? readFileSync(INSTALLED_HASH, "utf8").trim() : null;
76
+ if (have !== last) { // kept outside OpenSpec's schemas/ dir, which lists every folder there as a schema
77
+ backup = join(STORE_DIR, "schema-backups", `${SCHEMA_NAME}-${stamp(now)}`);
78
+ mkdirSync(join(STORE_DIR, "schema-backups"), { recursive: true });
79
+ renameSync(path, backup);
80
+ }
81
+ else rmSync(path, { recursive: true, force: true });
82
+ }
83
+ renameSync(tmp, path);
84
+ mkdirSync(STORE_DIR, { recursive: true });
85
+ writeFileSync(INSTALLED_HASH, want + "\n");
86
+ return backup ? { changed: true, path, backup } : { changed: true, path };
87
+ }
package/src/plugin.js CHANGED
@@ -3,18 +3,35 @@
3
3
  // dialogs are nested calls. Everything is wrapped so it can never throw at init.
4
4
  //
5
5
  // Commands:
6
- // /habithive umbrella menu (set model / switch mode / reset)
7
- // /model nested model tree: level (main / all / group / agent / category) -> model
6
+ // /habithive umbrella menu (model tree / switch mode / reset)
7
+ // /hive-models nested model tree (alias hivemodels): "This screen (live)" opens opencode's
8
+ // own picker; everything / group / agent / tier rows write oh-my-openagent.json
9
+ // and relaunch. A main-agent change also reorders model.json `recent` and leaves
10
+ // a pending-apply marker; the next load fires model.cycle_recent once to move the
11
+ // on-screen agent (opencode restores the session's last model over any config).
8
12
  // /habithive-mode switch complex <-> simple: queues a marker + quits (app.exit);
9
- // the `habithive` launcher relaunches in the queued mode, same session.
13
+ // the `habithive` launcher relaunches in the queued mode, same session.
10
14
 
11
15
  import {
12
16
  loadStore, saveStore, applyStore, loadBaseline, ensureBaseline,
13
17
  setModelOverride, clearModelOverride, effectiveModel, writeNextMode,
14
- setMainModel, currentMainModel, isMainAgent,
15
- KNOWN_CATEGORIES, MAIN_AGENTS, SUB_AGENTS,
18
+ writePendingApply, readPendingApply, clearPendingApply, arrangeRecent, revertedAgents,
19
+ isMainAgent, KNOWN_CATEGORIES, MAIN_AGENTS, SUB_AGENTS, ALL_AGENTS,
16
20
  } from "./core.js";
17
21
  import { registerLogo } from "./logo.js";
22
+ import { OPENCODE_PIN, compareVersions } from "./versions.js";
23
+ import { readCache, isDue, checkLatest, updateAvailable } from "./update.js";
24
+
25
+ const MAIN_KEYS = MAIN_AGENTS.map((a) => a.key);
26
+ const SUB_KEYS = SUB_AGENTS.map((a) => a.key);
27
+ const WHY = { sisyphus: "Sisyphus can't run this GPT model", hephaestus: "Hephaestus is GPT-only" };
28
+ let shown = false; // at most one "update available" toast per process
29
+ let SNAP = null; // overrides store as of plugin load = what this process runs; rows that differ are "pending relaunch"
30
+
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+ const bad = (r) => r === false || r?.ok === false;
33
+ // "Sisyphus (Ultraworker)" / "sisyphus" -> "sisyphus"
34
+ const agentKey = (a) => (/[a-z][a-z-]*/.exec(String(a ?? "").toLowerCase())?.[0] ?? "").replace(/-+$/, "");
18
35
 
19
36
  function listModels(api) {
20
37
  const out = [];
@@ -33,45 +50,157 @@ function listModels(api) {
33
50
  return out;
34
51
  }
35
52
 
36
- function toast(api, message, variant = "info") {
37
- try { api.ui.toast({ message, variant }); } catch {}
53
+ function toast(api, message, variant = "info", duration) {
54
+ try { api.ui.toast({ message, variant, ...(duration ? { duration } : {}) }); } catch {}
38
55
  }
39
56
 
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;
57
+ // Run an opencode built-in command: api.keymap (newer builds) else the deprecated api.command.trigger.
58
+ // Returns the call's result (true if undefined); false if neither exists or it throws.
59
+ function dispatch(api, name) {
45
60
  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 {}
61
+ const km = api?.keymap, trig = api?.command?.trigger;
62
+ const r = typeof km?.dispatchCommand === "function" ? km.dispatchCommand(name)
63
+ : typeof trig === "function" ? trig.call(api.command, name) : false;
64
+ if (typeof r?.then === "function") r.then(undefined, () => {});
65
+ return r === undefined ? true : r;
66
+ } catch { return false; }
67
+ }
68
+
69
+ // Resolves true once fn() is truthy; false on abort (plugin disposed) or after maxMs.
70
+ function until(api, ms, fn, maxMs = Infinity) {
71
+ return new Promise((res) => {
72
+ const sig = api?.lifecycle?.signal;
73
+ if (sig?.aborted) return res(false);
74
+ const t0 = Date.now();
75
+ let h;
76
+ const done = (v) => { clearInterval(h); sig?.removeEventListener?.("abort", stop); res(v); };
77
+ const stop = () => done(false);
78
+ const tick = () => { try { if (fn()) return done(true); } catch {} if (Date.now() - t0 >= maxMs) done(false); };
79
+ sig?.addEventListener?.("abort", stop, { once: true });
80
+ h = setInterval(tick, ms);
81
+ tick();
82
+ });
83
+ }
84
+
85
+ // ---- session probes (all tolerate a missing/older API) --------------------
86
+
87
+ function curSession(api) {
88
+ try { const c = api?.route?.current; return c && c.name === "session" && c.params?.sessionID ? c.params.sessionID : null; } catch { return null; }
89
+ }
90
+ function msgsOf(api, id) {
91
+ try { return api?.state?.session?.messages?.(id) || []; } catch { return []; }
92
+ }
93
+ // agent + "provider/model" of the session's last user message (what opencode restores on reopen)
94
+ function lastUser(api, id) {
95
+ const m = msgsOf(api, id);
96
+ for (let i = m.length - 1; i >= 0; i--) {
97
+ const x = m[i];
98
+ if (x?.role !== "user") continue;
99
+ const mm = x.model;
100
+ return { agent: agentKey(x.agent), model: mm?.providerID && mm?.modelID ? `${mm.providerID}/${mm.modelID}` : null };
101
+ }
102
+ return null;
103
+ }
104
+ function openPrompts(api, id) {
105
+ try {
106
+ const s = api.state.session;
107
+ return (s.permission?.(id)?.length || 0) + (s.question?.(id)?.length || 0) > 0;
108
+ } catch { return false; }
109
+ }
110
+
111
+ // ---- relaunch ---------------------------------------------------------------
112
+
113
+ // Queue a relaunch and quit; the launcher reopens (same session). Never quits without the launcher
114
+ // or while a permission/question prompt is open (app.exit would reject it); warns if the quit is ignored.
115
+ async function relaunch(api, mode = "complex", saved = true) {
116
+ if (process.env.HABITHIVE_LAUNCHER !== "1") {
117
+ toast(api, saved ? "saved — restart with `habithive -c` to apply" : `restart with \`habithive ${mode}\` to switch mode`, "warning");
118
+ return;
119
+ }
120
+ const id = curSession(api);
121
+ if (id && openPrompts(api, id)) {
122
+ toast(api, "waiting for the open prompt — the change applies once it's answered", "warning", 8000);
123
+ if (!(await until(api, 500, () => !openPrompts(api, id)))) return; // disposed while waiting
124
+ }
125
+ // Only target a REAL session (one with messages). A brand-new/empty session has no persisted id
126
+ // yet, so we leave it null and the launcher opens a fresh session.
127
+ const cur = curSession(api);
128
+ const sessionID = cur && msgsOf(api, cur).length > 0 ? cur : null;
53
129
  try { writeNextMode(mode, sessionID); }
54
130
  catch (e) { toast(api, `could not queue switch: ${String(e?.message || e)}`, "error"); return; }
55
131
  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 {}
132
+ dispatch(api, "app.exit");
133
+ const t = setTimeout(() => toast(api, "opencode did not quit — type /exit so habithive can reopen this session", "warning", 10000), 3000);
134
+ api?.lifecycle?.signal?.addEventListener?.("abort", () => clearTimeout(t), { once: true });
135
+ }
136
+
137
+ // ---- main-agent apply (design D2) -------------------------------------------
138
+
139
+ // toast the agents oh-my-openagent will reroute for this model; true if it warned
140
+ function warnReverted(api, id, keys) {
141
+ try {
142
+ const allow = loadBaseline()?.agents?.hephaestus?.allow_non_gpt_model === true;
143
+ const rev = revertedAgents(id, keys, { allowNonGptHephaestus: allow });
144
+ if (!rev?.length) return false;
145
+ toast(api, `${rev.map((k) => WHY[k] || k).join("; ")} — oh-my-openagent will reroute (keeps previous model)`, "warning", 7000);
146
+ return true;
147
+ } catch { return false; }
148
+ }
149
+
150
+ // If the on-screen agent is affected and runs another model Y, put [Y, X] first in `recent` and leave a
151
+ // marker so the reopened session can cycle Y -> X (opencode restores Y on reopen, over any config).
152
+ function queueApply(api, id, keys) {
153
+ try {
154
+ const sid = curSession(api);
155
+ if (!sid) return;
156
+ const lu = lastUser(api, sid);
157
+ if (!lu?.agent || !lu.model || !keys.includes(lu.agent) || lu.model === id) return;
158
+ const r = arrangeRecent(lu.model, id);
159
+ if (!r?.ok) { toast(api, `could not reorder recent models: ${r?.error || "unknown"}`, "warning"); return; }
160
+ writePendingApply({ sessionID: sid, agent: lu.agent, from: lu.model, to: id });
161
+ } catch (e) { toast(api, `could not queue apply: ${String(e?.message || e)}`, "warning"); }
162
+ }
163
+
164
+ // At load: wait for the session route + messages, then dispatch model.cycle_recent EXACTLY ONCE
165
+ // (each dispatch moves one step); fall back to the native picker if it can't run.
166
+ async function applyPending(api) {
167
+ const sig = api?.lifecycle?.signal;
168
+ let sid = null, mk = null, none = false;
169
+ const ready = await until(api, 300, () => {
170
+ if (!sid) {
171
+ const id = curSession(api);
172
+ if (!id) return false;
173
+ mk = readPendingApply(id);
174
+ sid = id;
175
+ if (!mk) return (none = true);
176
+ }
177
+ return msgsOf(api, sid).length > 0;
178
+ }, 20000);
179
+ if (none || !mk || sig?.aborted) return;
180
+ if (ready) {
181
+ await sleep(500);
182
+ if (sig?.aborted) return; // quitting: keep the marker for the next start
183
+ let r = dispatch(api, "model.cycle_recent");
184
+ try { r = await r; } catch { r = false; }
185
+ if (bad(r)) { dispatch(api, "model.list"); toast(api, `select ${mk.to} to apply it to ${mk.agent}`, "warning", 10000); }
186
+ else toast(api, `${mk.agent} → ${mk.to}`, "success");
59
187
  }
60
- if (!exited) toast(api, `Quit opencode — it relaunches in ${mode} mode (habithive launcher).`, "warning");
188
+ clearPendingApply();
61
189
  }
62
190
 
63
191
  // ---- model tree ----------------------------------------------------------
64
192
 
65
193
  function openModelPicker(api, level, target) {
66
194
  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)"
195
+ const current = level === "agent" || level === "category" || level === "all"
196
+ ? effectiveModel(loadStore(), loadBaseline(), level, target) : null;
197
+ const label = level === "all" ? "Everything (agents + tiers)"
72
198
  : level === "main-group" ? "ALL main agents"
73
199
  : level === "sub-group" ? "ALL sub agents"
74
200
  : `${level}: ${target}`;
201
+ // agents whose on-screen model this pick can change (null: subagents / tiers never touch the screen)
202
+ const keys = level === "main-group" ? MAIN_KEYS : level === "all" ? ALL_AGENTS
203
+ : level === "agent" && isMainAgent(target) ? [target] : null;
75
204
 
76
205
  const options = [
77
206
  { title: "↺ Clear override (use default)", value: { clear: true }, category: "Actions" },
@@ -87,72 +216,70 @@ function openModelPicker(api, level, target) {
87
216
  placeholder: "filter models…",
88
217
  options, current,
89
218
  onSelect: async (opt) => {
90
- const clear = !!opt.value.clear;
91
- const id = opt.value.id;
219
+ try {
220
+ const clear = !!opt.value.clear;
221
+ const id = opt.value.id;
222
+ const warned = keys && !clear ? warnReverted(api, id, keys) : false;
92
223
 
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
- }
224
+ const s = loadStore();
225
+ const setKeys = (ks) => ks.forEach((k) => clear ? clearModelOverride(s, "agent", k) : setModelOverride(s, "agent", k, id));
226
+ if (level === "main-group") setKeys(MAIN_KEYS);
227
+ else if (level === "sub-group") setKeys(SUB_KEYS);
228
+ else if (level === "all") { // Everything: one model for every agent + tier, so drop the more specific overrides
229
+ if (clear) clearModelOverride(s, "all");
230
+ else s.modelOverrides = { all: id, categories: {}, agents: {} };
231
+ }
232
+ else clear ? clearModelOverride(s, level, target) : setModelOverride(s, level, target, id);
114
233
 
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");
234
+ saveStore(s);
235
+ const r = applyStore(s);
236
+ api.ui.dialog.clear();
237
+ if (!r.ok) { toast(api, `apply failed: ${r.error}`, "error"); return; }
238
+ if (keys && !clear) queueApply(api, id, keys);
239
+ if (warned) await sleep(3000); // let the reroute warning be read before the relaunch toast replaces it
240
+ await relaunch(api);
241
+ } catch (e) { toast(api, `model change failed: ${String(e?.message || e)}`, "error"); }
122
242
  },
123
243
  }),
124
244
  );
125
245
  }
126
246
 
127
247
  function openLevelPicker(api) {
128
- ensureBaseline();
248
+ try { ensureBaseline(); } catch {}
129
249
  const store = loadStore();
130
250
  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)";
251
+ // Description = configured model; " · pending relaunch" when saved but not yet what this process runs.
252
+ const D = (fn) => { const now = fn(store); return now + (SNAP && fn(SNAP) !== now ? " · pending relaunch" : ""); };
253
+ const one = (lvl, tgt) => (s) => { const m = effectiveModel(s, baseline, lvl, tgt); return m ? `→ ${m}` : "→ (default)"; };
254
+ const group = (keys) => (s) => {
255
+ const u = [...new Set(keys.map((k) => effectiveModel(s, baseline, "agent", k)))];
256
+ return u.length === 1 ? (u[0] ? `→ all: ${u[0]}` : "→ (default)") : "→ (mixed)";
138
257
  };
139
258
  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" },
259
+ { title: "This screen (live)", description: "opencode's model picker · instant, no relaunch", value: { live: true }, category: "Live" },
260
+
261
+ { title: "★ Everything (agents + tiers)", description: D(one("all")), value: { level: "all" }, category: "Everything" },
142
262
 
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)" })),
263
+ { title: "★ All main agents", description: D(group(MAIN_KEYS)), value: { level: "main-group" }, category: "Main agents (primary)" },
264
+ ...MAIN_AGENTS.map((a) => ({ title: a.label, description: D(one("agent", a.key)), value: { level: "agent", target: a.key }, category: "Main agents (primary)" })),
145
265
 
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" })),
266
+ { title: "★ All sub agents", description: D(group(SUB_KEYS)), value: { level: "sub-group" }, category: "Subagents" },
267
+ ...SUB_AGENTS.map((a) => ({ title: a.label, description: D(one("agent", a.key)), value: { level: "agent", target: a.key }, category: "Subagents" })),
148
268
 
149
- ...KNOWN_CATEGORIES.map((c) => ({ title: c, description: fmt("category", c), value: { level: "category", target: c }, category: "Categories (tiers)" })),
269
+ ...KNOWN_CATEGORIES.map((c) => ({ title: c, description: D(one("category", c)), value: { level: "category", target: c }, category: "Categories (tiers)" })),
150
270
  ];
151
271
  api.ui.dialog.replace(() =>
152
272
  api.ui.DialogSelect({
153
- title: "habithive · set model — pick a level (most-specific wins)",
273
+ title: "habithive · model tree (most-specific wins)",
154
274
  placeholder: "filter…", options,
155
- onSelect: (opt) => openModelPicker(api, opt.value.level, opt.value.target),
275
+ onSelect: (opt) => {
276
+ if (opt.value.live) {
277
+ api.ui.dialog.clear();
278
+ if (bad(dispatch(api, "model.list"))) toast(api, "model picker unavailable — type /models", "warning");
279
+ return;
280
+ }
281
+ openModelPicker(api, opt.value.level, opt.value.target);
282
+ },
156
283
  }),
157
284
  );
158
285
  }
@@ -165,7 +292,7 @@ function openModeMenu(api) {
165
292
  { title: "→ Simple mode (vanilla opencode, fast)", value: "simple", description: "quits & relaunches vanilla, same session" },
166
293
  { title: "→ Complex mode (relaunch to apply model changes)", value: "complex", description: "quits & relaunches full, same session" },
167
294
  ],
168
- onSelect: (opt) => { api.ui.dialog.clear(); switchMode(api, opt.value); },
295
+ onSelect: (opt) => { api.ui.dialog.clear(); relaunch(api, opt.value, false); },
169
296
  }),
170
297
  );
171
298
  }
@@ -175,7 +302,7 @@ function openUmbrella(api) {
175
302
  api.ui.DialogSelect({
176
303
  title: "habithive-code",
177
304
  options: [
178
- { title: "Set model (main / all / groups / agent / tier)", value: "model" },
305
+ { title: "Model tree (this screen / everything / groups / agent / tier)", value: "model" },
179
306
  { title: "Switch mode (simple / complex)", value: "mode" },
180
307
  { title: "Reset all model overrides", value: "reset" },
181
308
  ],
@@ -202,12 +329,21 @@ const HabithiveTui = async (api) => {
202
329
  try { api.ui.toast({ message: "habithive-code: command API unavailable", variant: "warning" }); } catch {}
203
330
  return;
204
331
  }
332
+ try { SNAP = loadStore(); } catch {}
205
333
  reg(() => [
206
334
  { 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) },
335
+ { title: "habithive · model tree", value: "habithive.models", description: "this screen (live) / everything / groups / agent / tier", category: "habithive-code", slash: { name: "hive-models", aliases: ["hivemodels"] }, onSelect: () => openLevelPicker(api) },
208
336
  { 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
337
  ]);
210
338
  registerLogo(api); // v2 — HABITHIVE wordmark in home_logo (self-guarded, test-constructed)
339
+ const v = api.app?.version; // started without the launcher? still say when opencode is not the tested version
340
+ if (typeof v === "string" && v && compareVersions(v, OPENCODE_PIN) !== 0) toast(api, `opencode ${v} is not the tested ${OPENCODE_PIN} — run habithive update`, "warning");
341
+ // daily update check: toast from the cache now, refresh it in the background (never awaited, silent on failure)
342
+ const msg = (v) => `habithive v${v} available — quit and run habithive update`;
343
+ const cache = readCache();
344
+ if (updateAvailable(cache)) { shown = true; toast(api, msg(cache.latest), "info"); }
345
+ if (isDue(cache)) checkLatest().then((l) => { if (l && !shown && updateAvailable({ latest: l })) { shown = true; toast(api, msg(l), "info"); } }).catch(() => {});
346
+ applyPending(api).catch(() => {}); // non-blocking: finish a main-agent change after the relaunch
211
347
  } catch (e) {
212
348
  try { api.ui.toast({ message: `habithive-code init failed: ${String(e?.message || e)}`, variant: "error" }); } catch {}
213
349
  }