habithive-code 0.2.4 → 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/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/config.js
CHANGED
|
@@ -1,97 +1,243 @@
|
|
|
1
1
|
// habithive-code — opencode config wiring + mode switching.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
13
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
27
|
-
const
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
52
|
+
writeFileSync(tmp, text, "utf8");
|
|
39
53
|
renameSync(tmp, path);
|
|
40
54
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
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
|
|
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
|
|
64
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
94
|
-
|
|
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 (
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
// ----
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
+
}
|