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.
- package/CHANGELOG.md +94 -0
- package/README.md +88 -117
- package/bin/habithive.mjs +132 -10
- package/docs/COMPATIBILITY.md +158 -0
- package/docs/guide/01-getting-started.md +135 -0
- package/docs/guide/02-modes.md +105 -0
- package/docs/guide/03-model-tree.md +100 -0
- package/docs/guide/04-omniroute.md +98 -0
- package/docs/guide/05-spec-workflow.md +159 -0
- package/docs/guide/06-agents-skills-tools.md +97 -0
- package/docs/guide/07-updates-and-versions.md +115 -0
- package/docs/guide/08-files-and-safety.md +83 -0
- package/docs/guide/09-troubleshooting.md +117 -0
- package/docs/guide/README.md +32 -0
- package/openspec-schemas/intent-driven/schema.yaml +278 -0
- package/openspec-schemas/intent-driven/templates/adr.md +23 -0
- package/openspec-schemas/intent-driven/templates/design.md +19 -0
- package/openspec-schemas/intent-driven/templates/proposal.md +23 -0
- package/openspec-schemas/intent-driven/templates/spec.md +10 -0
- package/openspec-schemas/intent-driven/templates/tasks.md +9 -0
- package/package.json +8 -4
- package/src/config.js +211 -65
- package/src/core.js +70 -37
- package/src/jsonc.js +128 -0
- package/src/logo.js +1 -1
- package/src/omniroute.js +119 -0
- package/src/openspec.js +87 -0
- package/src/plugin.js +214 -78
- package/src/setup.js +173 -37
- package/src/spec.js +45 -0
- package/src/update.js +62 -0
- package/src/versions.js +71 -0
package/src/omniroute.js
ADDED
|
@@ -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
|
+
}
|
package/src/openspec.js
ADDED
|
@@ -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 (
|
|
7
|
-
// /
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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 === "
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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: "
|
|
141
|
-
|
|
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
|
|
144
|
-
...MAIN_AGENTS.map((a) => ({ title: a.label, description:
|
|
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
|
|
147
|
-
...SUB_AGENTS.map((a) => ({ title: a.label, description:
|
|
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:
|
|
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 ·
|
|
273
|
+
title: "habithive · model tree (most-specific wins)",
|
|
154
274
|
placeholder: "filter…", options,
|
|
155
|
-
onSelect: (opt) =>
|
|
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();
|
|
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: "
|
|
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 ·
|
|
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
|
}
|