@cmgmyr/hive 0.1.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/LICENSE +21 -0
- package/README.md +70 -0
- package/claude-plugin/.claude-plugin/plugin.json +7 -0
- package/claude-plugin/README.md +47 -0
- package/claude-plugin/hooks/hooks.json +14 -0
- package/claude-plugin/kickoff.mjs +41 -0
- package/claude-plugin/skills/cleanup/SKILL.md +60 -0
- package/dist/abi.js +220 -0
- package/dist/abiProbe.js +9 -0
- package/dist/backgroundTasks.js +51 -0
- package/dist/backup.js +378 -0
- package/dist/brief.js +113 -0
- package/dist/build-info.json +1 -0
- package/dist/claudeDir.js +5 -0
- package/dist/cli.js +2901 -0
- package/dist/codexHome.js +297 -0
- package/dist/config.js +72 -0
- package/dist/context.js +256 -0
- package/dist/contextCheckpoint.js +55 -0
- package/dist/dashboard.js +1158 -0
- package/dist/dataDir.js +89 -0
- package/dist/db.js +421 -0
- package/dist/dispatcher.js +150 -0
- package/dist/firstPrompt.js +6 -0
- package/dist/harnesses.js +173 -0
- package/dist/help.js +353 -0
- package/dist/hook.js +197 -0
- package/dist/hooks.js +50 -0
- package/dist/index.js +34 -0
- package/dist/kickoff.js +191 -0
- package/dist/leadMessage.js +98 -0
- package/dist/mcpConfig.js +121 -0
- package/dist/processes.js +161 -0
- package/dist/profiles.js +278 -0
- package/dist/projectYml.js +254 -0
- package/dist/prompts.js +18 -0
- package/dist/ptys.js +146 -0
- package/dist/result.js +53 -0
- package/dist/scheduler.js +1622 -0
- package/dist/sessionProbe.js +131 -0
- package/dist/slug.js +29 -0
- package/dist/spawn.js +370 -0
- package/dist/stateProvenance.js +97 -0
- package/dist/statusline.js +82 -0
- package/dist/strictInput.js +52 -0
- package/dist/teardown.js +89 -0
- package/dist/tmux.js +1295 -0
- package/dist/tools/agents.js +1246 -0
- package/dist/tools/kv.js +89 -0
- package/dist/tools/leases.js +87 -0
- package/dist/tools/meta.js +183 -0
- package/dist/tools/pads.js +279 -0
- package/dist/tools/params.js +14 -0
- package/dist/tools/todos.js +382 -0
- package/dist/tools/wakes.js +466 -0
- package/dist/transcript.js +123 -0
- package/dist/version.js +59 -0
- package/package.json +62 -0
- package/profiles/orchestration/posture.md +27 -0
- package/profiles/orchestration/runbook.md +72 -0
- package/profiles/orchestration/worker.md +38 -0
- package/profiles/simple/posture.md +14 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parse as parseToml } from "smol-toml";
|
|
6
|
+
import { gitPrimaryRoot } from "./context.js";
|
|
7
|
+
import { storeDir } from "./dataDir.js";
|
|
8
|
+
import { hookEntry } from "./hooks.js";
|
|
9
|
+
import { shellQuote } from "./tmux.js";
|
|
10
|
+
export function codexHomeDir(key) {
|
|
11
|
+
return join(storeDir(), "codex-homes", key);
|
|
12
|
+
}
|
|
13
|
+
export function codexRolloutsDir(key) {
|
|
14
|
+
return join(storeDir(), "codex-rollouts", key);
|
|
15
|
+
}
|
|
16
|
+
// undefined means transcriptPath does not point inside this home's sessions/.
|
|
17
|
+
export function preservedRolloutPath(key, transcriptPath) {
|
|
18
|
+
const sessionsPrefix = `${join(codexHomeDir(key), "sessions")}/`;
|
|
19
|
+
if (!transcriptPath.startsWith(sessionsPrefix))
|
|
20
|
+
return undefined;
|
|
21
|
+
return join(codexRolloutsDir(key), transcriptPath.slice(sessionsPrefix.length));
|
|
22
|
+
}
|
|
23
|
+
const ROLLOUT_FILE = /^rollout-.*\.jsonl$/;
|
|
24
|
+
// Dirent.isFile()/.isDirectory() answer about the entry itself, not its target, so a symlinked
|
|
25
|
+
// file or directory under sessions/ is neither and is silently skipped - never followed, never
|
|
26
|
+
// moved. That is what keeps this walk off the auth.json symlink without naming it specially.
|
|
27
|
+
function collectRolloutFiles(dir) {
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
if (e.code === "ENOENT")
|
|
34
|
+
return [];
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
const files = [];
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const full = join(dir, entry.name);
|
|
40
|
+
if (entry.isDirectory())
|
|
41
|
+
files.push(...collectRolloutFiles(full));
|
|
42
|
+
else if (entry.isFile() && ROLLOUT_FILE.test(entry.name))
|
|
43
|
+
files.push(full);
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
// A throw here must reach reapCodexHome before its rmSync, so a durable root that cannot be
|
|
48
|
+
// written leaves the home in place rather than losing the only copy of its rollout.
|
|
49
|
+
export function preserveCodexRollouts(key) {
|
|
50
|
+
const sessionsDir = join(codexHomeDir(key), "sessions");
|
|
51
|
+
if (!existsSync(sessionsDir))
|
|
52
|
+
return [];
|
|
53
|
+
const sources = collectRolloutFiles(sessionsDir);
|
|
54
|
+
const destRoot = codexRolloutsDir(key);
|
|
55
|
+
const moved = [];
|
|
56
|
+
for (const src of sources) {
|
|
57
|
+
const dest = join(destRoot, relative(sessionsDir, src));
|
|
58
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
59
|
+
try {
|
|
60
|
+
renameSync(src, dest);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
const code = e.code;
|
|
64
|
+
// ENOENT here is the close/janitor race, not a failure: a concurrent reap of this same key
|
|
65
|
+
// already moved this file. Whichever process's rename won, the file ends up preserved once.
|
|
66
|
+
if (code === "ENOENT") {
|
|
67
|
+
if (existsSync(dest))
|
|
68
|
+
moved.push(dest);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (code === "EXDEV") {
|
|
72
|
+
copyFileSync(src, dest);
|
|
73
|
+
unlinkSync(src);
|
|
74
|
+
moved.push(dest);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
moved.push(dest);
|
|
80
|
+
}
|
|
81
|
+
return moved;
|
|
82
|
+
}
|
|
83
|
+
// Never follow a link out of here: auth.json is a SYMLINK to the real ~/.codex/auth.json, and
|
|
84
|
+
// rmSync unlinks entries rather than resolving them. Prove any change against a real symlink.
|
|
85
|
+
export function reapCodexHome(key) {
|
|
86
|
+
const preserved = preserveCodexRollouts(key);
|
|
87
|
+
rmSync(codexHomeDir(key), { recursive: true, force: true });
|
|
88
|
+
return preserved;
|
|
89
|
+
}
|
|
90
|
+
// Built from character codes, not a literal \u escape range in source - see the reference on why.
|
|
91
|
+
const CONTROL_CHAR = new RegExp(`[${String.fromCharCode(0)}-${String.fromCharCode(0x1f)}${String.fromCharCode(0x7f)}]`, "g");
|
|
92
|
+
function tomlEscape(s, { allowLiteralNewline }) {
|
|
93
|
+
// Order matters: backslash/quote first, or the \u-escapes below get double-escaped.
|
|
94
|
+
const backslashAndQuote = s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
95
|
+
return backslashAndQuote.replace(CONTROL_CHAR, (ch) => {
|
|
96
|
+
if (allowLiteralNewline && ch === "\n")
|
|
97
|
+
return ch;
|
|
98
|
+
if (ch === "\t")
|
|
99
|
+
return "\\t";
|
|
100
|
+
if (ch === "\n")
|
|
101
|
+
return "\\n";
|
|
102
|
+
if (ch === "\r")
|
|
103
|
+
return "\\r";
|
|
104
|
+
return `\\u${ch.codePointAt(0).toString(16).padStart(4, "0")}`;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// Points at kickoff.js directly, never the `hive` PATH shim: this hook runs in the minimal-PATH
|
|
108
|
+
// environments hookEntry already warns about.
|
|
109
|
+
function kickoffHookEntry() {
|
|
110
|
+
const kickoffScript = join(dirname(fileURLToPath(import.meta.url)), "kickoff.js");
|
|
111
|
+
const node = shellQuote(process.execPath);
|
|
112
|
+
return { hooks: [{ type: "command", command: `${node} ${shellQuote(kickoffScript)} --codex` }] };
|
|
113
|
+
}
|
|
114
|
+
function tomlString(s) {
|
|
115
|
+
return `"${tomlEscape(s, { allowLiteralNewline: false })}"`;
|
|
116
|
+
}
|
|
117
|
+
function tomlStringArray(items) {
|
|
118
|
+
return `[${items.map(tomlString).join(", ")}]`;
|
|
119
|
+
}
|
|
120
|
+
// Verified accepted by codex 0.149.0 under --strict-config.
|
|
121
|
+
const HIVE_DEFAULT_STATUS_LINE = ["context-used"];
|
|
122
|
+
// null is the ordinary case (no config yet, or TOML codex would reject), never an error.
|
|
123
|
+
function readRealConfig(realConfigPath) {
|
|
124
|
+
if (!existsSync(realConfigPath))
|
|
125
|
+
return null;
|
|
126
|
+
try {
|
|
127
|
+
return parseToml(readFileSync(realConfigPath, "utf8"));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// ONLY [tui].status_line, never the rest of that table: it also carries model_availability_nux,
|
|
134
|
+
// which is not a display key.
|
|
135
|
+
function realStatusLine(realConfig) {
|
|
136
|
+
const statusLine = realConfig?.tui?.status_line;
|
|
137
|
+
if (!Array.isArray(statusLine) || !statusLine.every((v) => typeof v === "string"))
|
|
138
|
+
return null;
|
|
139
|
+
return statusLine;
|
|
140
|
+
}
|
|
141
|
+
// Top level, never under [tui]. null means "not set", so the caller omits the key and codex
|
|
142
|
+
// applies its own default rather than this module writing a partial one.
|
|
143
|
+
function realTopLevelInteger(realConfig, key) {
|
|
144
|
+
const value = realConfig?.[key];
|
|
145
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
146
|
+
}
|
|
147
|
+
function tomlMultilineString(s) {
|
|
148
|
+
// No trailing \n before the closing """, unlike the leading one - round-trips to the exact input.
|
|
149
|
+
return `"""\n${tomlEscape(s, { allowLiteralNewline: true })}"""`;
|
|
150
|
+
}
|
|
151
|
+
// codex has no AGENTS.local.md/CLAUDE.local.md of its own, so this order mirrors its real
|
|
152
|
+
// AGENTS.md-then-project_doc_fallback_filenames precedence rather than inventing a new one.
|
|
153
|
+
const LOCAL_OVERRIDE_FILENAMES = ["AGENTS.local.md", "CLAUDE.local.md"];
|
|
154
|
+
function readLocalOverride(projectRoot) {
|
|
155
|
+
for (const name of LOCAL_OVERRIDE_FILENAMES) {
|
|
156
|
+
const path = join(projectRoot, name);
|
|
157
|
+
try {
|
|
158
|
+
return { path, text: readFileSync(path, "utf8") };
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
if (e.code !== "ENOENT")
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
// Names the primary checkout on purpose: a worktree cwd has no copy of this gitignored file, so a
|
|
168
|
+
// worker reading its own brief must not go looking for one under its own path.
|
|
169
|
+
function localOverrideHeading(path) {
|
|
170
|
+
return `# Repo-local instructions from ${path} (the primary checkout's gitignored file; a worktree cwd has no copy, so do not look for it under your own path)`;
|
|
171
|
+
}
|
|
172
|
+
export function codexInstructionsPhrase(layers) {
|
|
173
|
+
return layers.length ? `instructions: ${layers.join(", ")}` : undefined;
|
|
174
|
+
}
|
|
175
|
+
// Must be written before any [section] header, or TOML nests it inside whichever table precedes it -
|
|
176
|
+
// accepted silently, delivered to nobody. See tmux-and-panes.md, "the brief-delivery TOML trap".
|
|
177
|
+
function configToml(input) {
|
|
178
|
+
return ([
|
|
179
|
+
`developer_instructions = ${tomlMultilineString(input.developerInstructions)}`,
|
|
180
|
+
// hive's own default, not copied: without it a worker in an AGENTS.md-less project reads no
|
|
181
|
+
// project doc at all.
|
|
182
|
+
`project_doc_fallback_filenames = ${tomlStringArray(["CLAUDE.md"])}`,
|
|
183
|
+
// Copied when set, never defaulted: hive cannot choose a context window on anyone's behalf.
|
|
184
|
+
...(input.modelContextWindow !== null ? [`model_context_window = ${input.modelContextWindow}`] : []),
|
|
185
|
+
...(input.modelAutoCompactTokenLimit !== null
|
|
186
|
+
? [`model_auto_compact_token_limit = ${input.modelAutoCompactTokenLimit}`]
|
|
187
|
+
: []),
|
|
188
|
+
"",
|
|
189
|
+
`[projects.${tomlString(input.projectRoot)}]`,
|
|
190
|
+
`trust_level = "trusted"`,
|
|
191
|
+
"",
|
|
192
|
+
`[mcp_servers.hive]`,
|
|
193
|
+
`command = ${tomlString(input.nodeBin)}`,
|
|
194
|
+
`args = [${tomlString(input.indexJs)}]`,
|
|
195
|
+
"",
|
|
196
|
+
`[mcp_servers.hive.env]`,
|
|
197
|
+
`HIVE_AGENT_ID = ${tomlString(input.actorId)}`,
|
|
198
|
+
"",
|
|
199
|
+
// Only accepted under [tui]; a top-level status_line is an unknown field to --strict-config.
|
|
200
|
+
`[tui]`,
|
|
201
|
+
`status_line = ${tomlStringArray(input.statusLine)}`,
|
|
202
|
+
].join("\n") + "\n");
|
|
203
|
+
}
|
|
204
|
+
// Pure on purpose: agent_resume reuses it against an EXISTING home, so it must not write.
|
|
205
|
+
export function codexLaunchArgs(cwd) {
|
|
206
|
+
const root = gitPrimaryRoot(cwd);
|
|
207
|
+
const commonDir = root ? join(root, ".git") : null;
|
|
208
|
+
return [
|
|
209
|
+
// Declining review of hive's OWN generated hooks.json, not a stranger's - see the reference.
|
|
210
|
+
"--dangerously-bypass-hook-trust",
|
|
211
|
+
// NOT parity with claude's posture, and --add-dir is NOT containment - see the reference.
|
|
212
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
213
|
+
// Inert alongside the flag above, kept because its value seeds [projects.<root>] trust.
|
|
214
|
+
...(commonDir ? ["--add-dir", commonDir] : []),
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
export function ensureCodexHooksFile(key, options) {
|
|
218
|
+
// Subagent hooks, not the Stop payload's background_tasks, which codex never sends. No
|
|
219
|
+
// Notification: unreachable for codex by design (test/codex-notify-unreachable.test.mjs).
|
|
220
|
+
const hooks = {
|
|
221
|
+
// A worker's SessionEnd would be a no-op anyway (HIVE_LEAD gates stopProcessesForEndedLead
|
|
222
|
+
// in src/hook.ts), so a worker home carries no hook that can never act.
|
|
223
|
+
...(options.lead ? { SessionStart: [kickoffHookEntry()], SessionEnd: [hookEntry("session_end")] } : {}),
|
|
224
|
+
...(!options.lead && options.includePostToolUse ? { PostToolUse: [hookEntry("post_tool_use", "codex")] } : {}),
|
|
225
|
+
Stop: [hookEntry("stop")],
|
|
226
|
+
UserPromptSubmit: [hookEntry("prompt")],
|
|
227
|
+
SubagentStart: [hookEntry("subagent_start")],
|
|
228
|
+
SubagentStop: [hookEntry("subagent_stop")],
|
|
229
|
+
};
|
|
230
|
+
writeFileSync(join(codexHomeDir(key), "hooks.json"), JSON.stringify({ hooks }, null, 2) + "\n");
|
|
231
|
+
return Object.keys(hooks);
|
|
232
|
+
}
|
|
233
|
+
// Generates a per-worker home: auth.json symlinked (never copied), hooks.json, config.toml.
|
|
234
|
+
export function ensureCodexHome(input) {
|
|
235
|
+
// Keep every refusing guard ABOVE the first write: a throw after one leaves a partial home the
|
|
236
|
+
// caller cannot tell from a real one.
|
|
237
|
+
if (!existsSync(process.execPath)) {
|
|
238
|
+
throw new Error(`hive: this process's own interpreter (${process.execPath}) no longer exists on disk; refusing to register a codex worker's MCP server under a path that would start nothing.`);
|
|
239
|
+
}
|
|
240
|
+
// Read before any write: a real (non-ENOENT) read error here must not leave a partial home.
|
|
241
|
+
const projectRoot = gitPrimaryRoot(input.cwd) ?? input.cwd;
|
|
242
|
+
const localOverride = readLocalOverride(projectRoot);
|
|
243
|
+
const developerInstructions = localOverride
|
|
244
|
+
? `${input.brief}\n\n${localOverrideHeading(localOverride.path)}\n\n${localOverride.text}`
|
|
245
|
+
: input.brief;
|
|
246
|
+
const home = codexHomeDir(input.key);
|
|
247
|
+
mkdirSync(home, { recursive: true });
|
|
248
|
+
const authSource = input.authSource ?? join(homedir(), ".codex", "auth.json");
|
|
249
|
+
if (!existsSync(authSource)) {
|
|
250
|
+
throw new Error(`hive: no codex credentials found at ${authSource}; run \`codex login\` before spawning a codex worker.`);
|
|
251
|
+
}
|
|
252
|
+
const authLink = join(home, "auth.json");
|
|
253
|
+
rmSync(authLink, { force: true });
|
|
254
|
+
symlinkSync(authSource, authLink);
|
|
255
|
+
// GLOBAL layer: the user's own ~/.codex/AGENTS.md. Symlinked, never copied, for the same reason
|
|
256
|
+
// auth.json is - a copy goes stale the moment the real file is edited. Absent is not an error:
|
|
257
|
+
// no link, no message, same as auth.json's presence is required but this is not.
|
|
258
|
+
const globalSource = join(dirname(authSource), "AGENTS.md");
|
|
259
|
+
const globalLink = join(home, "AGENTS.md");
|
|
260
|
+
rmSync(globalLink, { force: true });
|
|
261
|
+
const hasGlobal = existsSync(globalSource);
|
|
262
|
+
if (hasGlobal)
|
|
263
|
+
symlinkSync(globalSource, globalLink);
|
|
264
|
+
// Touch only the "cleanup" entry, never skills/ itself: codex keeps its own .system there.
|
|
265
|
+
if (input.lead) {
|
|
266
|
+
const cleanupSource = join(dirname(fileURLToPath(import.meta.url)), "..", "claude-plugin", "skills", "cleanup");
|
|
267
|
+
// Codex derives the `hive:cleanup` namespace from the root this link points into, so a
|
|
268
|
+
// dangling link loses the name a user types, not just the skill. Fail fast.
|
|
269
|
+
if (!existsSync(cleanupSource)) {
|
|
270
|
+
throw new Error(`hive: cleanup skill not found at ${cleanupSource}; this checkout's claude-plugin/ is missing or incomplete.`);
|
|
271
|
+
}
|
|
272
|
+
const skillsDir = join(home, "skills");
|
|
273
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
274
|
+
const cleanupLink = join(skillsDir, "cleanup");
|
|
275
|
+
rmSync(cleanupLink, { force: true });
|
|
276
|
+
symlinkSync(cleanupSource, cleanupLink);
|
|
277
|
+
}
|
|
278
|
+
const hooksWired = ensureCodexHooksFile(input.key, input);
|
|
279
|
+
const indexJs = join(dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
280
|
+
const realConfigPath = input.realConfigSource ?? join(homedir(), ".codex", "config.toml");
|
|
281
|
+
const realConfig = readRealConfig(realConfigPath);
|
|
282
|
+
const statusLine = realStatusLine(realConfig) ?? HIVE_DEFAULT_STATUS_LINE;
|
|
283
|
+
const modelContextWindow = realTopLevelInteger(realConfig, "model_context_window");
|
|
284
|
+
const modelAutoCompactTokenLimit = realTopLevelInteger(realConfig, "model_auto_compact_token_limit");
|
|
285
|
+
writeFileSync(join(home, "config.toml"), configToml({
|
|
286
|
+
developerInstructions,
|
|
287
|
+
projectRoot,
|
|
288
|
+
nodeBin: process.execPath,
|
|
289
|
+
indexJs,
|
|
290
|
+
actorId: input.actorId,
|
|
291
|
+
statusLine,
|
|
292
|
+
modelContextWindow,
|
|
293
|
+
modelAutoCompactTokenLimit,
|
|
294
|
+
}));
|
|
295
|
+
const instructionLayers = [...(hasGlobal ? ["global"] : []), ...(localOverride ? ["local"] : [])];
|
|
296
|
+
return { extraArgs: codexLaunchArgs(input.cwd), hooksWired, instructionLayers };
|
|
297
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { storeDir } from "./dataDir.js";
|
|
4
|
+
export const ATTACH_MODES = ["auto", "raw", "control"];
|
|
5
|
+
export function isAttachMode(value) {
|
|
6
|
+
return ATTACH_MODES.includes(value);
|
|
7
|
+
}
|
|
8
|
+
export const AUTO_ATTACH_MODES = ["auto", "on", "off"];
|
|
9
|
+
export function isAutoAttach(value) {
|
|
10
|
+
return AUTO_ATTACH_MODES.includes(value);
|
|
11
|
+
}
|
|
12
|
+
function configPath() {
|
|
13
|
+
return join(storeDir(), "config.json");
|
|
14
|
+
}
|
|
15
|
+
function readConfig() {
|
|
16
|
+
const path = configPath();
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = readFileSync(path, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function envAttachMode() {
|
|
33
|
+
const value = process.env.HIVE_ATTACH_MODE;
|
|
34
|
+
return isAttachMode(value) ? value : null;
|
|
35
|
+
}
|
|
36
|
+
function envAutoAttach() {
|
|
37
|
+
const value = process.env.HIVE_AUTO_ATTACH;
|
|
38
|
+
if (value === "0")
|
|
39
|
+
return "off";
|
|
40
|
+
return isAutoAttach(value) ? value : null;
|
|
41
|
+
}
|
|
42
|
+
export function resolvedAttachMode() {
|
|
43
|
+
const fromEnv = envAttachMode();
|
|
44
|
+
if (fromEnv)
|
|
45
|
+
return { mode: fromEnv, source: "env" };
|
|
46
|
+
const value = readConfig().attach;
|
|
47
|
+
return isAttachMode(value) ? { mode: value, source: "config" } : { mode: "auto", source: "detection" };
|
|
48
|
+
}
|
|
49
|
+
export function attachMode() {
|
|
50
|
+
return resolvedAttachMode().mode;
|
|
51
|
+
}
|
|
52
|
+
export function resolvedAutoAttach() {
|
|
53
|
+
const fromEnv = envAutoAttach();
|
|
54
|
+
if (fromEnv)
|
|
55
|
+
return { value: fromEnv, source: "env" };
|
|
56
|
+
const value = readConfig().autoAttach;
|
|
57
|
+
return isAutoAttach(value)
|
|
58
|
+
? { value, source: "config" }
|
|
59
|
+
: { value: "auto", source: "detection" };
|
|
60
|
+
}
|
|
61
|
+
export function setAttachMode(mode) {
|
|
62
|
+
const config = readConfig();
|
|
63
|
+
config.attach = mode;
|
|
64
|
+
mkdirSync(storeDir(), { recursive: true });
|
|
65
|
+
writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`);
|
|
66
|
+
}
|
|
67
|
+
export function setAutoAttach(value) {
|
|
68
|
+
const config = readConfig();
|
|
69
|
+
config.autoAttach = value;
|
|
70
|
+
mkdirSync(storeDir(), { recursive: true });
|
|
71
|
+
writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`);
|
|
72
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
4
|
+
import { basename, dirname, resolve, sep } from "node:path";
|
|
5
|
+
import { db } from "./db.js";
|
|
6
|
+
let selectedId = null;
|
|
7
|
+
let cachedActorId = null;
|
|
8
|
+
let lastTouchMs = 0;
|
|
9
|
+
let pendingRegistrationNotice = null;
|
|
10
|
+
export function takeRegistrationNotice() {
|
|
11
|
+
const notice = pendingRegistrationNotice;
|
|
12
|
+
pendingRegistrationNotice = null;
|
|
13
|
+
return notice;
|
|
14
|
+
}
|
|
15
|
+
export const TOUCH_INTERVAL_MS = 30_000;
|
|
16
|
+
export function currentActor() {
|
|
17
|
+
if (cachedActorId) {
|
|
18
|
+
if (Date.now() - lastTouchMs > TOUCH_INTERVAL_MS) {
|
|
19
|
+
db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(cachedActorId);
|
|
20
|
+
lastTouchMs = Date.now();
|
|
21
|
+
}
|
|
22
|
+
return cachedActorId;
|
|
23
|
+
}
|
|
24
|
+
const envId = process.env.HIVE_AGENT_ID;
|
|
25
|
+
const id = envId ?? `user:${userInfo().username}`;
|
|
26
|
+
const name = process.env.HIVE_AGENT_NAME ?? id;
|
|
27
|
+
const kind = envId ? "agent" : "human";
|
|
28
|
+
db.prepare(`INSERT INTO actors (id, name, kind, tmux_pane) VALUES (?, ?, ?, ?)
|
|
29
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
30
|
+
last_seen_at = datetime('now'),
|
|
31
|
+
tmux_pane = COALESCE(excluded.tmux_pane, actors.tmux_pane)`).run(id, name, kind, process.env.TMUX_PANE ?? null);
|
|
32
|
+
cachedActorId = id;
|
|
33
|
+
lastTouchMs = Date.now();
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
export function listProjects() {
|
|
37
|
+
return db.prepare("SELECT * FROM projects ORDER BY id").all();
|
|
38
|
+
}
|
|
39
|
+
export function getProject(id) {
|
|
40
|
+
return db.prepare("SELECT * FROM projects WHERE id = ?").get(id);
|
|
41
|
+
}
|
|
42
|
+
export function getProjectByPath(path) {
|
|
43
|
+
return db.prepare("SELECT * FROM projects WHERE path = ?").get(path);
|
|
44
|
+
}
|
|
45
|
+
export function addProject(path, name) {
|
|
46
|
+
let resolved;
|
|
47
|
+
try {
|
|
48
|
+
resolved = realpathSync(path ?? process.cwd());
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(`Path does not exist: ${path}`);
|
|
52
|
+
}
|
|
53
|
+
const existing = db.prepare("SELECT * FROM projects WHERE path = ?").get(resolved);
|
|
54
|
+
if (existing)
|
|
55
|
+
return existing;
|
|
56
|
+
db.prepare("INSERT INTO projects (name, path) VALUES (?, ?) ON CONFLICT(path) DO NOTHING").run(name ?? basename(resolved), resolved);
|
|
57
|
+
return db.prepare("SELECT * FROM projects WHERE path = ?").get(resolved);
|
|
58
|
+
}
|
|
59
|
+
function assertAccessible(id) {
|
|
60
|
+
const project = getProject(id);
|
|
61
|
+
if (!project)
|
|
62
|
+
throw new Error(`Unknown project_id ${id}. Call project_list to see options.`);
|
|
63
|
+
if (projectLock && id !== resolveHomeProject()) {
|
|
64
|
+
throw new Error(`This session is locked to project ${resolveHomeProject()} (HIVE_PROJECT_LOCK=1). Cross-project access is disabled.`);
|
|
65
|
+
}
|
|
66
|
+
return project;
|
|
67
|
+
}
|
|
68
|
+
export function selectProjectById(id) {
|
|
69
|
+
const project = assertAccessible(id);
|
|
70
|
+
selectedId = id;
|
|
71
|
+
return project;
|
|
72
|
+
}
|
|
73
|
+
function isSameOrUnder(path, root) {
|
|
74
|
+
return path === root || path.startsWith(root + sep);
|
|
75
|
+
}
|
|
76
|
+
function bestPrefixMatch(projects, dir) {
|
|
77
|
+
let best = null;
|
|
78
|
+
for (const p of projects) {
|
|
79
|
+
if (isSameOrUnder(dir, p.path)) {
|
|
80
|
+
if (!best || p.path.length > best.path.length)
|
|
81
|
+
best = p;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return best;
|
|
85
|
+
}
|
|
86
|
+
export function registeredAncestor(dir) {
|
|
87
|
+
const projects = listProjects().filter((project) => project.path !== dir);
|
|
88
|
+
return bestPrefixMatch(projects, dir);
|
|
89
|
+
}
|
|
90
|
+
export function gitPrimaryRoot(dir) {
|
|
91
|
+
try {
|
|
92
|
+
const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env;
|
|
93
|
+
const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
94
|
+
cwd: dir,
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
97
|
+
timeout: 2000,
|
|
98
|
+
env,
|
|
99
|
+
}).trim();
|
|
100
|
+
const resolvedCommonDir = resolve(dir, commonDir);
|
|
101
|
+
if (basename(resolvedCommonDir) !== ".git")
|
|
102
|
+
return null;
|
|
103
|
+
return realpathSync(dirname(resolvedCommonDir));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function gitDirs(dir) {
|
|
110
|
+
try {
|
|
111
|
+
const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env;
|
|
112
|
+
const out = execFileSync("git", ["rev-parse", "--git-dir", "--git-common-dir"], {
|
|
113
|
+
cwd: dir,
|
|
114
|
+
encoding: "utf8",
|
|
115
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
116
|
+
timeout: 2000,
|
|
117
|
+
env,
|
|
118
|
+
})
|
|
119
|
+
.trim()
|
|
120
|
+
.split("\n");
|
|
121
|
+
if (out.length !== 2)
|
|
122
|
+
return null;
|
|
123
|
+
return {
|
|
124
|
+
gitDir: realpathSync(resolve(dir, out[0])),
|
|
125
|
+
commonDir: realpathSync(resolve(dir, out[1])),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export function isLinkedWorktree(dir) {
|
|
133
|
+
return linkedWorktreePrimaryRoot(dir) !== null;
|
|
134
|
+
}
|
|
135
|
+
export function linkedWorktreePrimaryRoot(dir) {
|
|
136
|
+
const dirs = gitDirs(dir);
|
|
137
|
+
if (dirs === null || dirs.gitDir === dirs.commonDir)
|
|
138
|
+
return null;
|
|
139
|
+
return dirname(dirs.commonDir);
|
|
140
|
+
}
|
|
141
|
+
function hasStricterMatch(projects, directPath) {
|
|
142
|
+
return projects.some((p) => p.path !== directPath && isSameOrUnder(p.path, directPath));
|
|
143
|
+
}
|
|
144
|
+
function detectFromDir(dir) {
|
|
145
|
+
const projects = listProjects();
|
|
146
|
+
const direct = bestPrefixMatch(projects, dir);
|
|
147
|
+
if (direct && !hasStricterMatch(projects, direct.path)) {
|
|
148
|
+
return direct;
|
|
149
|
+
}
|
|
150
|
+
const root = gitPrimaryRoot(dir);
|
|
151
|
+
const git = root ? bestPrefixMatch(projects, root) : null;
|
|
152
|
+
if (git && (!direct || isSameOrUnder(git.path, direct.path))) {
|
|
153
|
+
return git;
|
|
154
|
+
}
|
|
155
|
+
return direct;
|
|
156
|
+
}
|
|
157
|
+
function detectFromCwd() {
|
|
158
|
+
return findProjectForDir(process.cwd());
|
|
159
|
+
}
|
|
160
|
+
export function findProjectForDir(dir) {
|
|
161
|
+
let resolved;
|
|
162
|
+
try {
|
|
163
|
+
resolved = realpathSync(dir);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return detectFromDir(resolved);
|
|
169
|
+
}
|
|
170
|
+
export function findProjectForCwd() {
|
|
171
|
+
return findProjectForDir(process.cwd());
|
|
172
|
+
}
|
|
173
|
+
const projectLock = process.env.HIVE_PROJECT_LOCK === "1";
|
|
174
|
+
let pinFailure = null;
|
|
175
|
+
function projectPathGuard(id) {
|
|
176
|
+
const expected = process.env.HIVE_PROJECT_PATH?.trim();
|
|
177
|
+
if (!expected)
|
|
178
|
+
return id;
|
|
179
|
+
const project = getProject(id);
|
|
180
|
+
let resolvedExpected;
|
|
181
|
+
try {
|
|
182
|
+
resolvedExpected = realpathSync(expected);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
resolvedExpected = expected;
|
|
186
|
+
}
|
|
187
|
+
if (resolvedExpected !== project.path) {
|
|
188
|
+
pinFailure = new Error(`This worker's project pin (agents row -> project ${id} at "${project.path}") disagrees with its spawn path HIVE_PROJECT_PATH="${expected}". The store may have been swapped underneath a live worker - restart it.`);
|
|
189
|
+
throw pinFailure;
|
|
190
|
+
}
|
|
191
|
+
return id;
|
|
192
|
+
}
|
|
193
|
+
export function agentProjectPin() {
|
|
194
|
+
if (pinFailure)
|
|
195
|
+
throw pinFailure;
|
|
196
|
+
const actorId = process.env.HIVE_AGENT_ID;
|
|
197
|
+
if (!actorId || process.env.HIVE_PROJECT_LOCK !== "1")
|
|
198
|
+
return null;
|
|
199
|
+
const row = db
|
|
200
|
+
.prepare("SELECT project_id, status FROM agents WHERE actor_id = ? ORDER BY (status = 'running') DESC, id DESC LIMIT 1")
|
|
201
|
+
.get(actorId);
|
|
202
|
+
if (!row) {
|
|
203
|
+
pinFailure = new Error(`HIVE_AGENT_ID=${actorId} names no agents row. This worker's store does not know it - most likely a rebuilt store. Restarting in this pane will not help: HIVE_AGENT_ID and HIVE_PROJECT_LOCK are set in the pane's own environment and survive a restart. Run "unset HIVE_AGENT_ID HIVE_PROJECT_LOCK HIVE_PROJECT_PATH" first, or open a new pane.`);
|
|
204
|
+
throw pinFailure;
|
|
205
|
+
}
|
|
206
|
+
if (row.status === "closed") {
|
|
207
|
+
const expectedPath = process.env.HIVE_PROJECT_PATH?.trim();
|
|
208
|
+
if (!expectedPath)
|
|
209
|
+
return null;
|
|
210
|
+
const byCwd = findProjectForCwd();
|
|
211
|
+
if (byCwd)
|
|
212
|
+
return byCwd.id;
|
|
213
|
+
const byPath = findProjectForDir(expectedPath);
|
|
214
|
+
if (byPath)
|
|
215
|
+
return byPath.id;
|
|
216
|
+
pinFailure = new Error(`HIVE_AGENT_ID=${actorId}'s agents row is closed. Its cwd resolves to no project, and its spawn path HIVE_PROJECT_PATH="${expectedPath}" no longer resolves to one either. Unset HIVE_AGENT_ID HIVE_PROJECT_LOCK HIVE_PROJECT_PATH in this pane, or start a new one.`);
|
|
217
|
+
throw pinFailure;
|
|
218
|
+
}
|
|
219
|
+
return projectPathGuard(row.project_id);
|
|
220
|
+
}
|
|
221
|
+
function resolveHomeProject() {
|
|
222
|
+
if (selectedId != null)
|
|
223
|
+
return selectedId;
|
|
224
|
+
const pinned = agentProjectPin();
|
|
225
|
+
if (pinned != null) {
|
|
226
|
+
selectedId = pinned;
|
|
227
|
+
return selectedId;
|
|
228
|
+
}
|
|
229
|
+
const detected = detectFromCwd();
|
|
230
|
+
if (detected != null) {
|
|
231
|
+
selectedId = detected.id;
|
|
232
|
+
return selectedId;
|
|
233
|
+
}
|
|
234
|
+
const created = addProject(gitPrimaryRoot(process.cwd()) ?? process.cwd());
|
|
235
|
+
selectedId = created.id;
|
|
236
|
+
pendingRegistrationNotice = created;
|
|
237
|
+
return selectedId;
|
|
238
|
+
}
|
|
239
|
+
export function effectiveProjectId(override) {
|
|
240
|
+
if (override != null) {
|
|
241
|
+
assertAccessible(override);
|
|
242
|
+
return override;
|
|
243
|
+
}
|
|
244
|
+
return resolveHomeProject();
|
|
245
|
+
}
|
|
246
|
+
export function resolveProject(override) {
|
|
247
|
+
return getProject(effectiveProjectId(override));
|
|
248
|
+
}
|
|
249
|
+
export function trySelectedProject() {
|
|
250
|
+
try {
|
|
251
|
+
return getProject(effectiveProjectId()) ?? null;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { storeDir } from "./dataDir.js";
|
|
4
|
+
import { readContextFill } from "./transcript.js";
|
|
5
|
+
export function contextCheckpointAdditionalContext(actorId, thresholdPercent, fill) {
|
|
6
|
+
if (!actorId.startsWith("agent:") || !Number.isInteger(thresholdPercent) || thresholdPercent < 1 || thresholdPercent > 100)
|
|
7
|
+
return null;
|
|
8
|
+
if (!Number.isSafeInteger(fill.used_tokens) || fill.used_tokens < 0 || !Number.isSafeInteger(fill.window_tokens) || fill.window_tokens <= 0)
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
const path = join(storeDir(), "context-checkpoints", `${encodeURIComponent(actorId)}.fired`);
|
|
12
|
+
if (fill.used_tokens * 100 < fill.window_tokens * thresholdPercent) {
|
|
13
|
+
if (existsSync(path))
|
|
14
|
+
unlinkSync(path);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (existsSync(path))
|
|
18
|
+
return null;
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
20
|
+
writeFileSync(path, "", { flag: "wx", mode: 0o600 });
|
|
21
|
+
return `Context is at ${fill.used_percent}% of its ${fill.window_tokens}-token window; the configured checkpoint threshold is ${thresholdPercent}%.`;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function runContextCheckpointHook(kind) {
|
|
28
|
+
const thresholdText = process.env.HIVE_CONTEXT_CHECKPOINT_PERCENT;
|
|
29
|
+
if (!thresholdText || !/^\d+$/.test(thresholdText))
|
|
30
|
+
return;
|
|
31
|
+
const threshold = Number(thresholdText);
|
|
32
|
+
if (!Number.isInteger(threshold) || threshold < 1 || threshold > 100)
|
|
33
|
+
return;
|
|
34
|
+
const actorId = process.env.HIVE_AGENT_ID ?? "";
|
|
35
|
+
if (!actorId.startsWith("agent:") || (kind !== "claude" && kind !== "codex"))
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
const payload = JSON.parse(readFileSync(0, "utf8"));
|
|
39
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload))
|
|
40
|
+
return;
|
|
41
|
+
const fill = readContextFill(kind, {
|
|
42
|
+
actor_id: actorId,
|
|
43
|
+
cwd: typeof payload.cwd === "string" ? payload.cwd : "",
|
|
44
|
+
session_id: typeof payload.session_id === "string" ? payload.session_id : "",
|
|
45
|
+
transcript_path: typeof payload.transcript_path === "string" ? payload.transcript_path : "",
|
|
46
|
+
});
|
|
47
|
+
if (fill === null)
|
|
48
|
+
return;
|
|
49
|
+
const additionalContext = contextCheckpointAdditionalContext(actorId, threshold, fill);
|
|
50
|
+
if (additionalContext !== null) {
|
|
51
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext } }));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|