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/src/config.js CHANGED
@@ -1,97 +1,243 @@
1
1
  // habithive-code — opencode config wiring + mode switching.
2
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:
3
+ // One COMPLETE config file set per mode (ADR-0008). The active mode's files sit at
4
+ // their normal paths in ~/.config/opencode; the inactive mode's files wait unchanged
5
+ // in ~/.habithive/modes/<mode>/. A switch only RENAMES files (journaled), never edits
6
+ // them. A missing set is created once from the active files; user config is never
7
+ // rewritten on launch. Hive's own record (~/.habithive/mode) is the active mode.
5
8
  // complex: oh-my-opencode ON (opencode.jsonc + tui.json) + habithive (tui.json)
6
9
  // simple : oh-my-opencode OFF + habithive (tui.json)
7
- // Preserves every other plugin/key. Atomic writes. Idempotent.
8
10
  //
9
11
  // 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.
12
+ // version. Bump OMO_SPEC (src/versions.js) in a new habithive-code release after re-testing.
11
13
 
12
- import { readFileSync, writeFileSync, existsSync, renameSync, mkdirSync } from "node:fs";
13
- import { join } from "node:path";
14
+ import {
15
+ readFileSync, writeFileSync, existsSync, renameSync, mkdirSync, copyFileSync,
16
+ unlinkSync, rmSync, openSync, fsyncSync, closeSync,
17
+ } from "node:fs";
18
+ import { join, dirname, basename } from "node:path";
14
19
  import { homedir } from "node:os";
15
- import { fileURLToPath } from "node:url";
20
+ import { readConfig, safeEdit, ConfigParseError } from "./jsonc.js";
21
+ import { STORE_DIR } from "./core.js";
22
+ import { OMO_SPEC } from "./versions.js";
16
23
 
17
- export const OMO_SPEC = "oh-my-openagent@4.19.1"; // pinned, tested version
24
+ export { OMO_SPEC }; // pinned, tested version (defined in versions.js)
18
25
  export const CONFIG_DIR = join(homedir(), ".config", "opencode");
19
26
  const OPENCODE_JSONC = join(CONFIG_DIR, "opencode.jsonc");
20
27
  const OPENCODE_JSON = join(CONFIG_DIR, "opencode.json");
21
28
  const TUI_JSON = join(CONFIG_DIR, "tui.json");
29
+ const SCHEMA = "https://opencode.ai/config.json";
30
+
31
+ export const MODE_FILE = join(STORE_DIR, "mode");
32
+ export const MODES_DIR = join(STORE_DIR, "modes");
33
+ const FILES_FILE = join(MODES_DIR, "files.json");
34
+ const JOURNAL = join(STORE_DIR, "switch.journal");
22
35
 
23
36
  // file:// URL to this package's plugin entry (works when installed globally or local).
24
37
  export const HABITHIVE_PLUGIN_URL = new URL("./plugin.js", import.meta.url).href;
25
38
 
26
- const isOmo = (e) => typeof e === "string" && /oh-my-open(agent|code)/.test(e);
27
- const isHabithive = (e) => typeof e === "string" && e.includes("habithive");
39
+ // plugin entries are "name" or ["name", options]
40
+ const nm = (e) => (Array.isArray(e) ? e[0] : e);
41
+ export function isOmo(e) { return typeof nm(e) === "string" && /oh-my-open(agent|code)/.test(nm(e)); }
42
+ export function isHabithive(e) { return typeof nm(e) === "string" && (/habit-?hive/.test(nm(e)) || nm(e) === HABITHIVE_PLUGIN_URL); }
28
43
 
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 });
44
+ const act = (f) => join(CONFIG_DIR, f);
45
+ const stored = (mode, f) => join(modeDir(mode), f);
46
+ const isMode = (m) => m === "simple" || m === "complex";
47
+ const where = (r) => `${r.file}:${r.line}:${r.column} ${r.message}`;
48
+
49
+ function put(path, text) { // atomic temp + rename
50
+ mkdirSync(dirname(path), { recursive: true });
37
51
  const tmp = `${path}.tmp-${process.pid}`;
38
- writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
52
+ writeFileSync(tmp, text, "utf8");
39
53
  renameSync(tmp, path);
40
54
  }
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);
55
+
56
+ // rename, falling back to copy + fsync + unlink across devices (EXDEV)
57
+ export function moveFile(src, dst) {
58
+ mkdirSync(dirname(dst), { recursive: true });
59
+ try { renameSync(src, dst); } catch (e) {
60
+ if (e.code !== "EXDEV") throw e;
61
+ const tmp = `${dst}.tmp-${process.pid}`;
62
+ copyFileSync(src, tmp);
63
+ const fd = openSync(tmp, "r+");
64
+ try { fsyncSync(fd); } finally { closeSync(fd); }
65
+ renameSync(tmp, dst);
66
+ unlinkSync(src);
67
+ }
68
+ }
69
+
70
+ export function modeDir(mode) { return join(MODES_DIR, mode); }
71
+
72
+ // ---- mode record -----------------------------------------------------------
73
+
74
+ export function readMode() {
75
+ try { const m = readFileSync(MODE_FILE, "utf8").trim(); return isMode(m) ? m : null; } catch { return null; }
76
+ }
77
+ export function writeMode(mode) {
78
+ if (!isMode(mode)) throw new Error(`unknown mode: ${mode}`);
79
+ put(MODE_FILE, mode + "\n");
80
+ }
81
+
82
+ // First run with no record: infer once (read-only on user config), then record it.
83
+ export function adoptMode() {
84
+ let mode = "simple", warning;
85
+ for (const p of [OPENCODE_JSONC, OPENCODE_JSON, TUI_JSON]) {
86
+ const r = readConfig(p);
87
+ if (!r.ok) { mode = "complex"; warning = `cannot read config (${where(r)}); assuming complex mode`; break; }
88
+ if (Array.isArray(r.value?.plugin) && r.value.plugin.some(isOmo)) mode = "complex";
56
89
  }
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);
90
+ writeMode(mode);
91
+ return warning ? { mode, warning } : { mode };
92
+ }
93
+
94
+ export function currentMode() { return readMode() ?? adoptMode().mode; }
95
+
96
+ // ---- per-mode file sets ----------------------------------------------------
97
+
98
+ // basenames Hive swaps; opencode.json only when it lists oh-my-openagent.
99
+ // Read-only: the list is persisted (once decided) by the first createModeSet.
100
+ export function managedFiles() {
101
+ try {
102
+ const l = JSON.parse(readFileSync(FILES_FILE, "utf8"));
103
+ if (Array.isArray(l) && l.length && l.every((f) => typeof f === "string" && f === basename(f))) return l;
104
+ } catch { /* not decided yet */ }
105
+ const r = readConfig(OPENCODE_JSON);
106
+ const list = ["opencode.jsonc", "tui.json"];
107
+ // unparseable opencode.json counts as managed so createModeSet aborts naming it
108
+ if (r.exists && (!r.ok || (Array.isArray(r.value?.plugin) && r.value.plugin.some(isOmo)))) list.push("opencode.json");
109
+ return list;
110
+ }
111
+
112
+ export function hasModeSet(mode) { return managedFiles().every((f) => existsSync(stored(mode, f))); }
113
+
114
+ // absolute paths of a mode's files: CONFIG_DIR if active, its store dir if stored, else null
115
+ export function filesForMode(mode) {
116
+ if (currentMode() === mode) return managedFiles().map(act);
117
+ return hasModeSet(mode) ? managedFiles().map((f) => stored(mode, f)) : null;
118
+ }
119
+
120
+ // the target-mode variant of one active file (v = parsed value, null when missing)
121
+ function variant(f, v, target) {
122
+ const c = structuredClone(v ?? (f === "opencode.jsonc" ? { $schema: SCHEMA } : {}));
123
+ const has = Array.isArray(c.plugin), complex = target === "complex";
124
+ const p = (has ? c.plugin : []).filter((e) => !isOmo(e));
125
+ if (f === "tui.json" && !p.some(isHabithive)) p.push(HABITHIVE_PLUGIN_URL);
126
+ if (complex && (has || f !== "opencode.json")) p.push(OMO_SPEC);
127
+ if (has || f === "tui.json" || (complex && f !== "opencode.json")) c.plugin = p;
128
+ return c;
129
+ }
130
+
131
+ // Create the target mode's file set once from the ACTIVE files. Never touches the active files.
132
+ export function createModeSet(target) {
133
+ if (!isMode(target)) return { ok: false, error: `unknown mode: ${target}` };
134
+ if (readMode() === target) return { ok: false, error: `${target} mode is already active` };
135
+ if (hasModeSet(target)) return { ok: true };
136
+ const files = managedFiles(), out = {};
137
+ for (const f of files) {
138
+ const r = readConfig(act(f));
139
+ if (!r.ok) return { ok: false, error: where(r) };
140
+ if (r.exists && (r.value === null || typeof r.value !== "object" || Array.isArray(r.value)))
141
+ return { ok: false, error: `${act(f)}: expected a JSON object` };
142
+ out[f] = variant(f, r.value, target);
143
+ }
144
+ for (const [f, v] of Object.entries(out)) put(stored(target, f), JSON.stringify(v, null, 2) + "\n");
145
+ if (!existsSync(FILES_FILE)) put(FILES_FILE, JSON.stringify(files, null, 2) + "\n"); // decided
146
+ return { ok: true };
147
+ }
148
+
149
+ // ---- journaled rename-only switch ------------------------------------------
150
+ // journal: { from, to, files, had } (had = files present at the active path when the switch began)
151
+ // per file: active -> stored(from), then stored(to) -> active. State is recovered from existence.
152
+
153
+ function roll(j, forward) {
154
+ for (const f of j.files) {
155
+ const a = act(f), A = stored(j.from, f), B = stored(j.to, f);
156
+ if (forward) {
157
+ if (existsSync(B)) { if (j.had.includes(f) && existsSync(a)) moveFile(a, A); moveFile(B, a); }
158
+ else if (!existsSync(a)) throw new Error(`${f} is missing from both ${j.to} store and config dir`);
159
+ } else {
160
+ if (!existsSync(B) && existsSync(a)) moveFile(a, B); // undo store(to) -> active
161
+ if (j.had.includes(f) && !existsSync(a) && existsSync(A)) moveFile(A, a); // undo active -> store(from)
65
162
  }
66
163
  }
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);
164
+ }
165
+
166
+ const validJournal = (j) => j && isMode(j.from) && isMode(j.to) && j.from !== j.to
167
+ && Array.isArray(j.files) && Array.isArray(j.had)
168
+ && j.files.every((f) => typeof f === "string" && f === basename(f)) && j.had.every((f) => j.files.includes(f));
169
+
170
+ // Complete (or, if that fails, roll back) an interrupted switch. Call at the start of every launch.
171
+ export function recoverSwitch() {
172
+ if (!existsSync(JOURNAL)) return { recovered: false };
173
+ let j;
174
+ try { j = JSON.parse(readFileSync(JOURNAL, "utf8")); } catch { j = null; }
175
+ if (!validJournal(j)) return { recovered: false, notice: `unreadable ${JOURNAL}; inspect ${MODES_DIR} and ${CONFIG_DIR}, then delete it` };
176
+ try {
177
+ roll(j, true);
178
+ writeMode(j.to);
179
+ rmSync(JOURNAL, { force: true });
180
+ return { recovered: true, notice: `completed interrupted switch to ${j.to} mode` };
181
+ } catch (e) {
182
+ try {
183
+ roll(j, false);
184
+ writeMode(j.from);
185
+ rmSync(JOURNAL, { force: true });
186
+ return { recovered: true, notice: `rolled back interrupted switch to ${j.to} mode (${e.message}); still ${j.from} mode` };
187
+ } catch (e2) {
188
+ return { recovered: false, notice: `could not recover interrupted switch (${e2.message}); see ${JOURNAL}` };
189
+ }
75
190
  }
76
- return `config set to ${mode} mode`;
77
191
  }
78
192
 
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;
193
+ // Switch to `mode` by renaming each mode's file set. Never edits file contents once both sets exist.
194
+ export function switchTo(mode) {
195
+ if (!isMode(mode)) return { ok: false, notices: [], error: `unknown mode: ${mode}` };
196
+ const notices = [];
197
+ if (existsSync(JOURNAL)) {
198
+ const r = recoverSwitch();
199
+ if (r.notice) notices.push(r.notice);
200
+ if (existsSync(JOURNAL)) return { ok: false, notices, error: `interrupted switch not recovered (${JOURNAL})` };
201
+ }
202
+ const from = currentMode();
203
+ if (from === mode) return { ok: true, notices };
204
+ if (!hasModeSet(mode)) {
205
+ const r = createModeSet(mode);
206
+ if (!r.ok) return { ok: false, notices, error: r.error };
207
+ }
208
+ const files = managedFiles();
209
+ const j = { from, to: mode, files, had: files.filter((f) => existsSync(act(f))) };
210
+ put(JOURNAL, JSON.stringify(j));
211
+ try { roll(j, true); } catch (e) {
212
+ try { roll(j, false); rmSync(JOURNAL, { force: true }); } catch { /* journal stays for recoverSwitch */ }
213
+ return { ok: false, notices, error: `switch to ${mode} failed: ${e.message}` };
214
+ }
215
+ writeMode(mode);
216
+ rmSync(JOURNAL, { force: true });
217
+ notices.push(`each mode keeps its own config — changes made in ${from} mode are not in ${mode} mode`);
218
+ return { ok: true, notices };
88
219
  }
89
220
 
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";
221
+ // ---- explicit config edits (setup/update only; never called by a switch) ----
222
+
223
+ // Add an MCP server if not already present (in the file or its sibling opencode.json).
224
+ // Non-destructive; returns true if added. Throws ConfigParseError when a file is unreadable.
225
+ export function ensureMcp(name, spec, file = PATHS.OPENCODE_JSONC) {
226
+ const sib = join(dirname(file), "opencode.json");
227
+ if (sib !== file) {
228
+ const r = readConfig(sib);
229
+ if (!r.ok) throw new ConfigParseError(r.file, r.line, r.column, r.message);
230
+ if (r.value?.mcp?.[name]) return false;
231
+ }
232
+ let added = false;
233
+ safeEdit(file, (c) => {
234
+ if (c.mcp?.[name]) return c;
235
+ if (!Object.keys(c).length) c.$schema = SCHEMA;
236
+ c.mcp = { ...(c.mcp || {}), [name]: spec };
237
+ added = true;
238
+ return c;
239
+ });
240
+ return added;
94
241
  }
95
242
 
96
243
  export const PATHS = { OPENCODE_JSONC, OPENCODE_JSON, TUI_JSON, CONFIG_DIR };
97
- export { fileURLToPath };
package/src/core.js CHANGED
@@ -3,15 +3,18 @@
3
3
  // Owns:
4
4
  // 1. The model-tree store + resolver, compiled into oh-my-opencode's
5
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.
6
+ // 2. Helpers over opencode's picker state model.json (recent order) + the
7
+ // pending-apply marker that finishes a main-agent switch after relaunch.
7
8
  // 3. The mode marker used to hand an in-app simple/complex switch to the CLI.
8
9
  //
9
10
  // Model resolution tree (most specific wins):
10
11
  // agent override > category override > "all" override > baseline
11
12
 
12
13
  import { homedir } from "node:os";
13
- import { join } from "node:path";
14
+ import { join, dirname } from "node:path";
14
15
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, copyFileSync, rmSync } from "node:fs";
16
+ import { createHash } from "node:crypto";
17
+ import { parseJsonc } from "./jsonc.js";
15
18
 
16
19
  const HOME = homedir();
17
20
  export const CONFIG_DIR = join(HOME, ".config", "opencode");
@@ -20,10 +23,14 @@ export const STORE_DIR = join(HOME, ".habithive");
20
23
  export const STORE_FILE = join(STORE_DIR, "overrides.json");
21
24
  export const BASELINE_FILE = join(STORE_DIR, "baseline.json");
22
25
  export const OMO_BACKUP = join(STORE_DIR, "oh-my-openagent.first-backup.json");
26
+ // sha256 of the oh-my-openagent.json text Hive last wrote (tells our writes from the user's/OMO's)
27
+ export const LAST_APPLIED = join(STORE_DIR, "last-applied.sha256");
23
28
  export const MODE_MARKER = join(STORE_DIR, "next-mode");
24
29
  export const SESSION_MARKER = join(STORE_DIR, "next-session");
25
- // opencode's native model-picker state — recent[0] is the current on-screen model.
30
+ // opencode's native model-picker state (recent / favorite / variant).
26
31
  export const MODEL_STATE = join(HOME, ".local", "state", "opencode", "model.json");
32
+ export const PENDING_APPLY = join(STORE_DIR, "pending-apply.json");
33
+ const PENDING_TTL = 10 * 60 * 1000;
27
34
 
28
35
  // Authoritative from oh-my-opencode's BuiltinCategoryNameSchema — 8 tiers.
29
36
  export const KNOWN_CATEGORIES = [
@@ -60,19 +67,23 @@ export function defaultStore() {
60
67
  function readJson(path, fallback) {
61
68
  try {
62
69
  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);
70
+ const r = parseJsonc(readFileSync(path, "utf8")); // string-safe JSONC (globs/URLs intact)
71
+ return r.ok ? r.value : fallback;
66
72
  } catch { return fallback; }
67
73
  }
68
74
 
69
75
  function writeJsonAtomic(path, obj) {
70
- mkdirSync(STORE_DIR, { recursive: true });
76
+ mkdirSync(dirname(path), { recursive: true });
77
+ const text = JSON.stringify(obj, null, 2) + "\n";
71
78
  const tmp = `${path}.tmp-${process.pid}-${globalThis.performance ? Math.floor(performance.now()) : "t"}`;
72
- writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
79
+ writeFileSync(tmp, text, "utf8");
73
80
  renameSync(tmp, path);
81
+ return text;
74
82
  }
75
83
 
84
+ const sha = (t) => createHash("sha256").update(t).digest("hex");
85
+ const hasContent = (o) => !!o && typeof o === "object" && Object.keys(o).some((k) => k !== "$schema");
86
+
76
87
  export function loadStore() {
77
88
  const s = readJson(STORE_FILE, null);
78
89
  if (!s) return defaultStore();
@@ -88,12 +99,20 @@ export function loadStore() {
88
99
 
89
100
  export function saveStore(store) { writeJsonAtomic(STORE_FILE, store); }
90
101
 
102
+ // Snapshot oh-my-openagent.json once (Reset restores it). A missing file is a valid, EMPTY
103
+ // baseline (fresh install: OMO only writes its config when the user does). An empty baseline is
104
+ // re-captured once the file gains content Hive did not write (user/OMO edits), never from our own output.
91
105
  export function ensureBaseline() {
92
106
  mkdirSync(STORE_DIR, { recursive: true });
93
- if (!existsSync(BASELINE_FILE)) {
94
- const current = readJson(OMO_CONFIG, {});
107
+ const saved = existsSync(BASELINE_FILE) ? readJson(BASELINE_FILE, {}) : null;
108
+ if (saved && hasContent(saved)) return saved;
109
+ let raw = null;
110
+ try { raw = existsSync(OMO_CONFIG) ? readFileSync(OMO_CONFIG, "utf8") : null; } catch {}
111
+ const current = readJson(OMO_CONFIG, {});
112
+ const ours = raw != null && existsSync(LAST_APPLIED) && readFileSync(LAST_APPLIED, "utf8").trim() === sha(raw);
113
+ if (!saved || (hasContent(current) && !ours)) {
95
114
  writeJsonAtomic(BASELINE_FILE, current);
96
- try { if (existsSync(OMO_CONFIG)) copyFileSync(OMO_CONFIG, OMO_BACKUP); } catch {}
115
+ try { if (raw != null) copyFileSync(OMO_CONFIG, OMO_BACKUP); } catch {}
97
116
  }
98
117
  return readJson(BASELINE_FILE, {});
99
118
  }
@@ -150,11 +169,9 @@ export function compileOmoConfig(store, baseline) {
150
169
 
151
170
  export function applyStore(store) {
152
171
  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));
172
+ const baseline = ensureBaseline(); // {} on a fresh install: write only the overrides
173
+ const text = writeJsonAtomic(OMO_CONFIG, compileOmoConfig(store, baseline));
174
+ writeFileSync(LAST_APPLIED, sha(text) + "\n", "utf8");
158
175
  return { ok: true };
159
176
  } catch (e) {
160
177
  return { ok: false, error: String(e?.message || e) };
@@ -184,7 +201,7 @@ export function effectiveModel(store, baseline, level, target) {
184
201
  return null;
185
202
  }
186
203
 
187
- // ---- on-screen (primary) model = model.json recent[0] --------------------
204
+ // ---- model.json recent order + pending-apply marker -----------------------
188
205
 
189
206
  function splitModelId(idStr) {
190
207
  const i = String(idStr).indexOf("/");
@@ -192,33 +209,49 @@ function splitModelId(idStr) {
192
209
  return { providerID: idStr.slice(0, i), modelID: idStr.slice(i + 1) };
193
210
  }
194
211
 
195
- export function currentMainModel() {
212
+ // Rewrite model.json `recent` so opencode's model.cycle_recent moves the agent from -> to.
213
+ // from set: [from, to, ...rest]; else [to, ...rest]. favorite/variant untouched.
214
+ export function arrangeRecent(from, to) {
196
215
  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
+ const t = splitModelId(to);
217
+ if (!t) return { ok: false, error: "bad model id (expected provider/model)" };
218
+ const f = from && from !== to ? splitModelId(from) : null;
219
+ if (from && from !== to && !f) return { ok: false, error: "bad model id (expected provider/model)" };
220
+ const state = readJson(MODEL_STATE, null);
221
+ const base = state && typeof state === "object" && !Array.isArray(state) ? state : {};
222
+ const same = (m, p) => m?.providerID === p.providerID && m?.modelID === p.modelID;
223
+ const rest = (Array.isArray(base.recent) ? base.recent : []).filter((m) => !same(m, t) && !(f && same(m, f)));
224
+ mkdirSync(dirname(MODEL_STATE), { recursive: true });
225
+ writeJsonAtomic(MODEL_STATE, { ...base, recent: f ? [f, t, ...rest] : [t, ...rest] });
216
226
  return { ok: true };
217
227
  } catch (e) {
218
228
  return { ok: false, error: String(e?.message || e) };
219
229
  }
220
230
  }
221
231
 
232
+ export function writePendingApply({ sessionID, agent, from, to }, now = Date.now()) {
233
+ writeJsonAtomic(PENDING_APPLY, { sessionID, agent, from, to, createdAt: now });
234
+ }
235
+ export function readPendingApply(sessionID, now = Date.now()) {
236
+ const p = readJson(PENDING_APPLY, null);
237
+ if (!p || typeof p !== "object" || p.sessionID !== sessionID) return null;
238
+ return now - p.createdAt <= PENDING_TTL ? p : null;
239
+ }
240
+ export function clearPendingApply() {
241
+ try { rmSync(PENDING_APPLY, { force: true }); } catch {}
242
+ }
243
+
244
+ // Agents oh-my-openagent (4.19.1 hooks) reroutes for this model: Sisyphus on older GPTs
245
+ // (gpt-5.4+/5.x-codex are accepted), Hephaestus on non-GPT models.
246
+ export function revertedAgents(modelId, agentKeys, { allowNonGptHephaestus = false } = {}) {
247
+ const id = String(modelId).split("/").pop().toLowerCase();
248
+ const gpt = id.includes("gpt");
249
+ const out = [];
250
+ if (agentKeys.includes("sisyphus") && gpt && !/gpt-5[.-](?:(?:3[.-])?codex|[4-9]|\d{2,})/i.test(id)) out.push("sisyphus");
251
+ if (agentKeys.includes("hephaestus") && !gpt && !allowNonGptHephaestus) out.push("hephaestus");
252
+ return out;
253
+ }
254
+
222
255
  // ---- mode marker (in-app switch handshake with the `habithive` launcher) ------
223
256
 
224
257
  export function writeNextMode(mode, session) {
package/src/jsonc.js ADDED
@@ -0,0 +1,128 @@
1
+ // habithive-code — safe JSONC reader/editor (pure Node ESM, no deps).
2
+ //
3
+ // parseJsonc: comments + trailing commas stripped by a string-aware scanner that
4
+ // blanks them in place (offsets never shift), so errors map to the original text.
5
+ // safeEdit: parse (abort on error) -> mutate a clone -> no-op if canonically equal
6
+ // -> timestamped backup -> temp file + rename. Keeps the newest `keep` backups.
7
+
8
+ import { readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, readdirSync, rmSync, statSync, chmodSync, existsSync } from "node:fs";
9
+ import { dirname, basename, join } from "node:path";
10
+
11
+ export class ConfigParseError extends Error {
12
+ constructor(file, line, column, reason) {
13
+ super(`${file}:${line}:${column} ${reason}`);
14
+ this.name = "ConfigParseError";
15
+ Object.assign(this, { file, line, column, reason });
16
+ }
17
+ }
18
+
19
+ const BAK = ".habithive-bak-";
20
+
21
+ function strip(text) {
22
+ const c = text.split(""), n = c.length;
23
+ const blank = (a, b) => { for (let k = a; k < b; k++) if (c[k] !== "\n" && c[k] !== "\r") c[k] = " "; };
24
+ let i = 0, comma = -1;
25
+ if (c[0] === "") c[i++] = " ";
26
+ while (i < n) {
27
+ const ch = c[i];
28
+ if (ch === '"') {
29
+ for (i++; i < n && c[i] !== '"'; i++) if (c[i] === "\\") i++;
30
+ i++; comma = -1;
31
+ } else if (ch === "/" && c[i + 1] === "/") {
32
+ let j = i; while (j < n && c[j] !== "\n" && c[j] !== "\r") j++;
33
+ blank(i, j); i = j;
34
+ } else if (ch === "/" && c[i + 1] === "*") {
35
+ const j = text.indexOf("*/", i + 2);
36
+ if (j < 0) return { at: i, why: "Unterminated block comment" };
37
+ blank(i, j + 2); i = j + 2;
38
+ } else {
39
+ if (ch === "}" || ch === "]") { if (comma >= 0) c[comma] = " "; comma = -1; }
40
+ else if (ch === ",") comma = i;
41
+ else if (!/\s/.test(ch)) comma = -1;
42
+ i++;
43
+ }
44
+ }
45
+ return { s: c.join("") };
46
+ }
47
+
48
+ const posOf = (m) => { const x = /position (\d+)/.exec(m); return x ? +x[1] : null; };
49
+ const atEnd = (m) => /end of JSON input|EOF/i.test(m);
50
+
51
+ // A prefix is "dead" once it fails for a reason other than running out of input.
52
+ function dead(s, k) {
53
+ try { JSON.parse(s.slice(0, k)); return false; }
54
+ catch (e) { const p = posOf(e.message); return !(atEnd(e.message) || (p !== null && p >= k)); }
55
+ }
56
+
57
+ function errAt(s, msg) {
58
+ const p = posOf(msg), end = s.trimEnd().length;
59
+ if (p !== null) return Math.min(p, end);
60
+ if (atEnd(msg)) return end;
61
+ let lo = 1, hi = s.length;
62
+ while (lo < hi) { const mid = (lo + hi) >> 1; dead(s, mid) ? (hi = mid) : (lo = mid + 1); }
63
+ return lo - 1;
64
+ }
65
+
66
+ const where = (t, p) => { const a = t.slice(0, p).split("\n"); return { line: a.length, column: a[a.length - 1].length + 1 }; };
67
+
68
+ export function parseJsonc(text) {
69
+ const r = strip(text);
70
+ if (r.why) return { ok: false, ...where(text, r.at), message: r.why };
71
+ try { return { ok: true, value: JSON.parse(r.s) }; }
72
+ catch (e) {
73
+ const message = e.message.replace(/, .* is not valid JSON$/s, "").replace(/( in JSON)? at position \d+( \(line \d+ column \d+\))?/, "");
74
+ return { ok: false, ...where(text, errAt(r.s, e.message)), message };
75
+ }
76
+ }
77
+
78
+ export function readConfig(path) {
79
+ let raw;
80
+ try { raw = readFileSync(path, "utf8"); }
81
+ catch (e) { if (e.code === "ENOENT") return { ok: true, exists: false, value: null, raw: null }; throw e; }
82
+ const r = parseJsonc(raw);
83
+ if (r.ok) return { ok: true, exists: true, value: r.value, raw };
84
+ return { ok: false, exists: true, raw, file: path, line: r.line, column: r.column, message: r.message };
85
+ }
86
+
87
+ const sortKeys = (v) => Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object"
88
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v;
89
+ const canon = (v) => JSON.stringify(sortKeys(v));
90
+
91
+ const p2 = (n) => String(n).padStart(2, "0");
92
+ const stamp = (d) => `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
93
+
94
+ function prune(path, keep) {
95
+ const dir = dirname(path), pre = basename(path) + BAK;
96
+ const key = (f) => { const m = /^(\d{8}-\d{6})(?:-(\d+))?$/.exec(f.slice(pre.length)); return m && [m[1], +(m[2] || 1)]; };
97
+ const all = readdirSync(dir).filter((f) => f.startsWith(pre) && key(f))
98
+ .sort((a, b) => { const x = key(a), y = key(b); return x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : x[1] - y[1]; });
99
+ for (const f of all.slice(0, Math.max(0, all.length - keep))) rmSync(join(dir, f), { force: true });
100
+ }
101
+
102
+ export function safeEdit(path, mutate, { now = new Date(), keep = 5, createIfMissing = true } = {}) {
103
+ const r = readConfig(path);
104
+ if (!r.ok) throw new ConfigParseError(r.file, r.line, r.column, r.message);
105
+ if (!r.exists && !createIfMissing) return { changed: false };
106
+ const before = r.exists ? r.value : {};
107
+ const clone = structuredClone(before);
108
+ const out = mutate(clone);
109
+ const next = out === undefined ? clone : out;
110
+ if (canon(before) === canon(next)) return { changed: false };
111
+ const res = { changed: true };
112
+ if (r.exists) {
113
+ const base = path + BAK + stamp(now);
114
+ let bak = base;
115
+ for (let n = 2; existsSync(bak); n++) bak = `${base}-${n}`;
116
+ copyFileSync(path, bak);
117
+ res.backup = bak;
118
+ }
119
+ mkdirSync(dirname(path), { recursive: true });
120
+ const tmp = `${path}.tmp-${process.pid}`;
121
+ try {
122
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n");
123
+ if (r.exists) chmodSync(tmp, statSync(path).mode & 0o777);
124
+ renameSync(tmp, path);
125
+ } catch (e) { rmSync(tmp, { force: true }); throw e; }
126
+ prune(path, keep);
127
+ return res;
128
+ }
package/src/logo.js CHANGED
@@ -49,7 +49,7 @@ export async function registerLogo(api) {
49
49
  const makeTitle = () => new AsciiFont(rctx, { text: TITLE_TEXT, font: FONT, color: [FILL, OUTLINE] });
50
50
  const makeCaption = () => new TextRenderable(rctx, { content: CAPTION_TEXT, fg: CAPTION_COLOR });
51
51
  const make = () => {
52
- const box = new BoxRenderable(rctx, { flexDirection: "column", rowGap: 1 });
52
+ const box = new BoxRenderable(rctx, { flexDirection: "column", alignItems: "center", rowGap: 1 });
53
53
  const title = makeTitle();
54
54
  const caption = makeCaption();
55
55
  box.add(title);