@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
package/dist/dataDir.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
export const DEFAULT_DATA_DIR = join(homedir(), ".hive");
|
|
7
|
+
function resolveDataDir() {
|
|
8
|
+
return process.env.HIVE_DATA_DIR ? resolve(process.env.HIVE_DATA_DIR) : DEFAULT_DATA_DIR;
|
|
9
|
+
}
|
|
10
|
+
const canonical = (path) => {
|
|
11
|
+
try {
|
|
12
|
+
return realpathSync(path);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
export function isDefaultStore(dir) {
|
|
19
|
+
return dir === DEFAULT_DATA_DIR || canonical(dir) === canonical(DEFAULT_DATA_DIR);
|
|
20
|
+
}
|
|
21
|
+
export function underTestRunner() {
|
|
22
|
+
return process.env.NODE_TEST_CONTEXT != null || process.execArgv.includes("--test");
|
|
23
|
+
}
|
|
24
|
+
function productEntryPoints() {
|
|
25
|
+
const dist = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
return [
|
|
27
|
+
join(dist, "cli.js"),
|
|
28
|
+
join(dist, "index.js"),
|
|
29
|
+
join(dist, "hook.js"),
|
|
30
|
+
join(dist, "statusline.js"),
|
|
31
|
+
join(dist, "kickoff.js"),
|
|
32
|
+
join(dist, "..", "claude-plugin", "kickoff.mjs"),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
export function isProductEntryPoint() {
|
|
36
|
+
const argv1 = process.argv[1];
|
|
37
|
+
if (!argv1)
|
|
38
|
+
return false;
|
|
39
|
+
const entry = canonical(argv1);
|
|
40
|
+
return productEntryPoints().some((p) => canonical(p) === entry);
|
|
41
|
+
}
|
|
42
|
+
export function storeDir() {
|
|
43
|
+
const dir = resolveDataDir();
|
|
44
|
+
const reason = defaultStoreRefusal(dir);
|
|
45
|
+
if (reason)
|
|
46
|
+
throw new Error(refusal(reason));
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
export function guardStoreDir() {
|
|
50
|
+
const dir = resolveDataDir();
|
|
51
|
+
const reason = defaultStoreRefusal(dir);
|
|
52
|
+
if (reason) {
|
|
53
|
+
console.error(`hive: ${refusal(reason)}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
function defaultStoreRefusal(dir) {
|
|
59
|
+
if (!isDefaultStore(dir))
|
|
60
|
+
return null;
|
|
61
|
+
if (underTestRunner())
|
|
62
|
+
return "test-runner";
|
|
63
|
+
if (isProductEntryPoint())
|
|
64
|
+
return null;
|
|
65
|
+
if (process.env.HIVE_ALLOW_DEFAULT_STORE === "1")
|
|
66
|
+
return null;
|
|
67
|
+
return "not-product-entry";
|
|
68
|
+
}
|
|
69
|
+
function refusal(reason) {
|
|
70
|
+
const why = reason === "test-runner"
|
|
71
|
+
? "hive refuses the real store whenever a test runner is the entry point " +
|
|
72
|
+
"(NODE_TEST_CONTEXT is set), because a suite that reaches it can destroy live state."
|
|
73
|
+
: "hive refuses the real store from any process that is not its own CLI, MCP server, " +
|
|
74
|
+
"or hooks, because a hand-rolled script that opens it to seed or inspect a row can " +
|
|
75
|
+
"write to live state by accident. Set HIVE_ALLOW_DEFAULT_STORE=1 if this really is a " +
|
|
76
|
+
"deliberate one-off against the real store.";
|
|
77
|
+
return (`refused to use its real store at ${DEFAULT_DATA_DIR}. Set HIVE_DATA_DIR to a directory ` +
|
|
78
|
+
"this run may write to, and pass it to every process spawned from here. " +
|
|
79
|
+
why);
|
|
80
|
+
}
|
|
81
|
+
export function tagFor(dir) {
|
|
82
|
+
const resolved = canonical(dir);
|
|
83
|
+
return isDefaultStore(resolved)
|
|
84
|
+
? ""
|
|
85
|
+
: `${createHash("sha256").update(resolved).digest("hex").slice(0, 8)}-`;
|
|
86
|
+
}
|
|
87
|
+
export function dataDirTag() {
|
|
88
|
+
return tagFor(storeDir());
|
|
89
|
+
}
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { guardAbi } from "./abi.js";
|
|
5
|
+
import { guardStoreDir } from "./dataDir.js";
|
|
6
|
+
import { maybeBackupBeforeMigrations } from "./backup.js";
|
|
7
|
+
import { errorMessage } from "./result.js";
|
|
8
|
+
export const dataDir = guardStoreDir();
|
|
9
|
+
mkdirSync(dataDir, { recursive: true });
|
|
10
|
+
function sleepSync(ms) {
|
|
11
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
12
|
+
}
|
|
13
|
+
guardAbi();
|
|
14
|
+
const storePath = join(dataDir, "hive.db");
|
|
15
|
+
export const db = new Database(storePath);
|
|
16
|
+
const openedInode = statSync(storePath).ino;
|
|
17
|
+
let storeReplacedLatch = false;
|
|
18
|
+
export function storeReplaced() {
|
|
19
|
+
if (storeReplacedLatch)
|
|
20
|
+
return true;
|
|
21
|
+
let current;
|
|
22
|
+
try {
|
|
23
|
+
current = statSync(storePath).ino;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
const code = e.code;
|
|
27
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
28
|
+
return false;
|
|
29
|
+
storeReplacedLatch = true;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (current !== openedInode)
|
|
33
|
+
storeReplacedLatch = true;
|
|
34
|
+
return storeReplacedLatch;
|
|
35
|
+
}
|
|
36
|
+
db.pragma("busy_timeout = 5000");
|
|
37
|
+
for (let attempt = 0;; attempt++) {
|
|
38
|
+
try {
|
|
39
|
+
db.pragma("journal_mode = WAL");
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (attempt >= 40 || !(e instanceof Error) || !/database is locked/.test(e.message))
|
|
44
|
+
throw e;
|
|
45
|
+
sleepSync(25);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
db.pragma("foreign_keys = ON");
|
|
49
|
+
export const MIGRATIONS = [
|
|
50
|
+
`
|
|
51
|
+
CREATE TABLE projects (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
name TEXT NOT NULL,
|
|
54
|
+
path TEXT NOT NULL UNIQUE,
|
|
55
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE actors (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
name TEXT NOT NULL,
|
|
61
|
+
kind TEXT NOT NULL DEFAULT 'human',
|
|
62
|
+
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE scratchpads (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
68
|
+
name TEXT NOT NULL,
|
|
69
|
+
content TEXT NOT NULL DEFAULT '',
|
|
70
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
71
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
72
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
updated_by TEXT,
|
|
74
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX idx_scratchpads_project ON scratchpads(project_id, archived, name);
|
|
77
|
+
CREATE UNIQUE INDEX idx_scratchpads_active_name
|
|
78
|
+
ON scratchpads(project_id, name) WHERE archived = 0;
|
|
79
|
+
|
|
80
|
+
CREATE TABLE todos (
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
83
|
+
title TEXT NOT NULL,
|
|
84
|
+
body TEXT NOT NULL DEFAULT '',
|
|
85
|
+
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('high', 'medium', 'low')),
|
|
86
|
+
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'backlog', 'completed')),
|
|
87
|
+
locked_by TEXT REFERENCES actors(id) ON DELETE SET NULL,
|
|
88
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
89
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
90
|
+
completed_at TEXT,
|
|
91
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
92
|
+
);
|
|
93
|
+
CREATE INDEX idx_todos_project ON todos(project_id, status, updated_at DESC);
|
|
94
|
+
|
|
95
|
+
CREATE TABLE todo_blockers (
|
|
96
|
+
todo_id INTEGER NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
|
|
97
|
+
blocker_id INTEGER NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
|
|
98
|
+
PRIMARY KEY (todo_id, blocker_id),
|
|
99
|
+
CHECK (todo_id != blocker_id)
|
|
100
|
+
);
|
|
101
|
+
CREATE INDEX idx_todo_blockers_blocker ON todo_blockers(blocker_id);
|
|
102
|
+
|
|
103
|
+
CREATE TABLE todo_comments (
|
|
104
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
105
|
+
todo_id INTEGER NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
|
|
106
|
+
author TEXT NOT NULL,
|
|
107
|
+
body TEXT NOT NULL,
|
|
108
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
109
|
+
);
|
|
110
|
+
CREATE INDEX idx_todo_comments_todo ON todo_comments(todo_id, created_at);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE kv (
|
|
113
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
114
|
+
key TEXT NOT NULL,
|
|
115
|
+
value TEXT NOT NULL,
|
|
116
|
+
updated_by TEXT,
|
|
117
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
118
|
+
expires_at TEXT,
|
|
119
|
+
PRIMARY KEY (project_id, key)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
CREATE TABLE locks (
|
|
123
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
124
|
+
lock_key TEXT NOT NULL,
|
|
125
|
+
owner TEXT NOT NULL REFERENCES actors(id),
|
|
126
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
127
|
+
expires_at TEXT NOT NULL,
|
|
128
|
+
PRIMARY KEY (project_id, lock_key)
|
|
129
|
+
);
|
|
130
|
+
`,
|
|
131
|
+
`
|
|
132
|
+
CREATE TABLE agents (
|
|
133
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
134
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
135
|
+
actor_id TEXT NOT NULL DEFAULT '',
|
|
136
|
+
name TEXT NOT NULL,
|
|
137
|
+
tmux_target TEXT NOT NULL DEFAULT '',
|
|
138
|
+
command TEXT NOT NULL,
|
|
139
|
+
cwd TEXT NOT NULL,
|
|
140
|
+
parent_actor_id TEXT,
|
|
141
|
+
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'closed')),
|
|
142
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
143
|
+
closed_at TEXT
|
|
144
|
+
);
|
|
145
|
+
CREATE INDEX idx_agents_project ON agents(project_id, status);
|
|
146
|
+
`,
|
|
147
|
+
`
|
|
148
|
+
ALTER TABLE agents ADD COLUMN agent_state TEXT NOT NULL DEFAULT 'unknown';
|
|
149
|
+
ALTER TABLE agents ADD COLUMN state_changed_at TEXT;
|
|
150
|
+
ALTER TABLE actors ADD COLUMN tmux_pane TEXT;
|
|
151
|
+
|
|
152
|
+
CREATE TABLE timers (
|
|
153
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
154
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
155
|
+
owner TEXT NOT NULL,
|
|
156
|
+
body TEXT NOT NULL,
|
|
157
|
+
kind TEXT NOT NULL DEFAULT 'delay' CHECK (kind IN ('delay', 'idle_any', 'idle_all')),
|
|
158
|
+
watch TEXT NOT NULL DEFAULT '[]',
|
|
159
|
+
deliver_actor TEXT NOT NULL,
|
|
160
|
+
deliver_pane TEXT NOT NULL,
|
|
161
|
+
due_at TEXT,
|
|
162
|
+
max_wait_at TEXT,
|
|
163
|
+
repeat_every_ms INTEGER,
|
|
164
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
165
|
+
fired_at TEXT,
|
|
166
|
+
cancelled_at TEXT,
|
|
167
|
+
fire_count INTEGER NOT NULL DEFAULT 0
|
|
168
|
+
);
|
|
169
|
+
CREATE INDEX idx_timers_active ON timers(project_id, kind)
|
|
170
|
+
WHERE cancelled_at IS NULL;
|
|
171
|
+
`,
|
|
172
|
+
`
|
|
173
|
+
ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'agent';
|
|
174
|
+
|
|
175
|
+
CREATE TABLE command_trust (
|
|
176
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
177
|
+
name TEXT NOT NULL,
|
|
178
|
+
config_hash TEXT NOT NULL,
|
|
179
|
+
trusted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
180
|
+
PRIMARY KEY (project_id, name, config_hash)
|
|
181
|
+
);
|
|
182
|
+
`,
|
|
183
|
+
`
|
|
184
|
+
UPDATE agents
|
|
185
|
+
SET name = name || '-' || id
|
|
186
|
+
WHERE status = 'running'
|
|
187
|
+
AND EXISTS (
|
|
188
|
+
SELECT 1 FROM agents AS other
|
|
189
|
+
WHERE other.project_id = agents.project_id
|
|
190
|
+
AND other.status = 'running'
|
|
191
|
+
AND other.name = agents.name COLLATE NOCASE
|
|
192
|
+
AND other.id < agents.id
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
CREATE UNIQUE INDEX idx_agents_running_name
|
|
196
|
+
ON agents(project_id, name COLLATE NOCASE) WHERE status = 'running';
|
|
197
|
+
`,
|
|
198
|
+
`
|
|
199
|
+
CREATE TABLE agent_state_log (
|
|
200
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
201
|
+
actor_id TEXT NOT NULL,
|
|
202
|
+
event TEXT NOT NULL,
|
|
203
|
+
state TEXT NOT NULL,
|
|
204
|
+
payload TEXT NOT NULL DEFAULT '',
|
|
205
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
206
|
+
);
|
|
207
|
+
-- (actor_id, id) is for the human forensic query, which is this table's whole
|
|
208
|
+
-- purpose: "show me every state this worker was recorded in, in order, with the
|
|
209
|
+
-- event and payload that decided it". Nothing in src/ reads it, deliberately.
|
|
210
|
+
-- Named here so it does not read as an index nobody uses.
|
|
211
|
+
CREATE INDEX idx_agent_state_log_actor ON agent_state_log(actor_id, id);
|
|
212
|
+
CREATE INDEX idx_agent_state_log_created ON agent_state_log(created_at);
|
|
213
|
+
`,
|
|
214
|
+
`
|
|
215
|
+
ALTER TABLE timers ADD COLUMN typed_at TEXT;
|
|
216
|
+
ALTER TABLE timers ADD COLUMN held_at TEXT;
|
|
217
|
+
ALTER TABLE timers ADD COLUMN held_reason TEXT;
|
|
218
|
+
ALTER TABLE timers ADD COLUMN confirmed_at TEXT;
|
|
219
|
+
`,
|
|
220
|
+
`
|
|
221
|
+
ALTER TABLE timers ADD COLUMN typed_busy INTEGER;
|
|
222
|
+
`,
|
|
223
|
+
`
|
|
224
|
+
ALTER TABLE agents ADD COLUMN tmux_socket TEXT NOT NULL DEFAULT '';
|
|
225
|
+
`,
|
|
226
|
+
`
|
|
227
|
+
CREATE INDEX idx_agents_actor_id ON agents(actor_id);
|
|
228
|
+
`,
|
|
229
|
+
`
|
|
230
|
+
ALTER TABLE todos ADD COLUMN archived_at TEXT;
|
|
231
|
+
`,
|
|
232
|
+
`
|
|
233
|
+
CREATE TABLE dashboard_meta (
|
|
234
|
+
project_id INTEGER PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
|
|
235
|
+
last_attempt_at TEXT,
|
|
236
|
+
last_mark TEXT
|
|
237
|
+
);
|
|
238
|
+
`,
|
|
239
|
+
`
|
|
240
|
+
CREATE TABLE wake_block_notices (
|
|
241
|
+
timer_id INTEGER NOT NULL REFERENCES timers(id) ON DELETE CASCADE,
|
|
242
|
+
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
243
|
+
blocked_since TEXT NOT NULL,
|
|
244
|
+
notified_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
245
|
+
PRIMARY KEY (timer_id, agent_id, blocked_since)
|
|
246
|
+
);
|
|
247
|
+
`,
|
|
248
|
+
`
|
|
249
|
+
ALTER TABLE timers ADD COLUMN watch_scope TEXT;
|
|
250
|
+
ALTER TABLE timers ADD COLUMN parent_timer_id INTEGER REFERENCES timers(id) ON DELETE CASCADE;
|
|
251
|
+
CREATE INDEX idx_timers_parent ON timers(parent_timer_id) WHERE parent_timer_id IS NOT NULL;
|
|
252
|
+
|
|
253
|
+
CREATE TABLE wake_idle_notices (
|
|
254
|
+
timer_id INTEGER NOT NULL REFERENCES timers(id) ON DELETE CASCADE,
|
|
255
|
+
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
256
|
+
condition TEXT NOT NULL,
|
|
257
|
+
episode TEXT NOT NULL,
|
|
258
|
+
notice_timer_id INTEGER,
|
|
259
|
+
notified_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
260
|
+
PRIMARY KEY (timer_id, agent_id, condition, episode)
|
|
261
|
+
);
|
|
262
|
+
`,
|
|
263
|
+
`
|
|
264
|
+
CREATE INDEX idx_wake_idle_notices_notified ON wake_idle_notices(notified_at);
|
|
265
|
+
`,
|
|
266
|
+
`
|
|
267
|
+
CREATE TRIGGER guard_scratchpads_content_update
|
|
268
|
+
BEFORE UPDATE ON scratchpads
|
|
269
|
+
FOR EACH ROW
|
|
270
|
+
WHEN NEW.content IS NOT OLD.content AND NEW.updated_at IS NOT datetime('now')
|
|
271
|
+
BEGIN
|
|
272
|
+
SELECT RAISE(ABORT, 'Refused: this UPDATE changes scratchpads.content but leaves updated_at unchanged, which every hive pad tool (pad_write, pad_edit, pad_append) stamps in the same statement. To overwrite a large pad, use hive pad <name> --save <file>, which does this correctly. If you must run SQL directly, address the row BY PRIMARY KEY (id), never by name: pad names are unique per project, not globally, so a name-only WHERE clause matches every project''s pad with that name and silently overwrites the wrong project''s data.');
|
|
273
|
+
END;
|
|
274
|
+
|
|
275
|
+
CREATE TRIGGER guard_todos_content_update
|
|
276
|
+
BEFORE UPDATE ON todos
|
|
277
|
+
FOR EACH ROW
|
|
278
|
+
WHEN (NEW.title IS NOT OLD.title OR NEW.body IS NOT OLD.body) AND NEW.updated_at IS NOT datetime('now')
|
|
279
|
+
BEGIN
|
|
280
|
+
SELECT RAISE(ABORT, 'Refused: this UPDATE changes todos.title or todos.body but leaves updated_at unchanged, which todo_update stamps in the same statement. Use todo_update, or if you must run SQL directly, address the row BY PRIMARY KEY (id) and stamp updated_at yourself.');
|
|
281
|
+
END;
|
|
282
|
+
|
|
283
|
+
CREATE TRIGGER guard_kv_content_update
|
|
284
|
+
BEFORE UPDATE ON kv
|
|
285
|
+
FOR EACH ROW
|
|
286
|
+
WHEN NEW.value IS NOT OLD.value AND NEW.updated_at IS NOT datetime('now')
|
|
287
|
+
BEGIN
|
|
288
|
+
SELECT RAISE(ABORT, 'Refused: this UPDATE changes kv.value but leaves updated_at unchanged, which kv_set stamps in the same statement. kv''s primary key is (project_id, key), not key alone: a key-only WHERE clause matches every project''s row with that key. Use kv_set, or if you must run SQL directly, filter on project_id too and stamp updated_at yourself.');
|
|
289
|
+
END;
|
|
290
|
+
`,
|
|
291
|
+
`
|
|
292
|
+
ALTER TABLE agents ADD COLUMN pane_pid TEXT NOT NULL DEFAULT '';
|
|
293
|
+
`,
|
|
294
|
+
`
|
|
295
|
+
ALTER TABLE agents ADD COLUMN session_id TEXT NOT NULL DEFAULT '';
|
|
296
|
+
`,
|
|
297
|
+
`
|
|
298
|
+
ALTER TABLE agents ADD COLUMN parked_at TEXT NOT NULL DEFAULT '';
|
|
299
|
+
ALTER TABLE agents ADD COLUMN parked_branch TEXT NOT NULL DEFAULT '';
|
|
300
|
+
`,
|
|
301
|
+
`
|
|
302
|
+
ALTER TABLE agents ADD COLUMN resumed_at TEXT NOT NULL DEFAULT '';
|
|
303
|
+
`,
|
|
304
|
+
`
|
|
305
|
+
ALTER TABLE todos ADD COLUMN slug TEXT NOT NULL DEFAULT '';
|
|
306
|
+
`,
|
|
307
|
+
`
|
|
308
|
+
ALTER TABLE timers ADD COLUMN typed_seen TEXT;
|
|
309
|
+
`,
|
|
310
|
+
`
|
|
311
|
+
ALTER TABLE timers ADD COLUMN first_held_at TEXT;
|
|
312
|
+
`,
|
|
313
|
+
`
|
|
314
|
+
CREATE TABLE agent_messages (
|
|
315
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
316
|
+
project_id INTEGER NOT NULL,
|
|
317
|
+
from_actor TEXT NOT NULL,
|
|
318
|
+
from_name TEXT NOT NULL,
|
|
319
|
+
to_agent_id INTEGER NOT NULL,
|
|
320
|
+
text TEXT NOT NULL,
|
|
321
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
322
|
+
);
|
|
323
|
+
-- AUTOINCREMENT is load-bearing, not decoration: sqlite_sequence keeps the highest id ever
|
|
324
|
+
-- issued even after the janitor deletes every row, and that is the only thing that lets
|
|
325
|
+
-- agent_message_get tell a PRUNED id from one that never existed (src/leadMessage.ts).
|
|
326
|
+
CREATE INDEX idx_agent_messages_project ON agent_messages(project_id, id);
|
|
327
|
+
`,
|
|
328
|
+
`
|
|
329
|
+
ALTER TABLE agents ADD COLUMN codex_home TEXT NOT NULL DEFAULT '';
|
|
330
|
+
`,
|
|
331
|
+
`
|
|
332
|
+
ALTER TABLE agents ADD COLUMN exit_tail TEXT NOT NULL DEFAULT '';
|
|
333
|
+
`,
|
|
334
|
+
`
|
|
335
|
+
ALTER TABLE agents ADD COLUMN transcript_path TEXT NOT NULL DEFAULT '';
|
|
336
|
+
`,
|
|
337
|
+
`
|
|
338
|
+
ALTER TABLE scratchpads RENAME TO pads;
|
|
339
|
+
|
|
340
|
+
DROP INDEX idx_scratchpads_project;
|
|
341
|
+
DROP INDEX idx_scratchpads_active_name;
|
|
342
|
+
CREATE INDEX idx_pads_project ON pads(project_id, archived, name);
|
|
343
|
+
CREATE UNIQUE INDEX idx_pads_active_name
|
|
344
|
+
ON pads(project_id, name) WHERE archived = 0;
|
|
345
|
+
|
|
346
|
+
DROP TRIGGER guard_scratchpads_content_update;
|
|
347
|
+
CREATE TRIGGER guard_pads_content_update
|
|
348
|
+
BEFORE UPDATE ON pads
|
|
349
|
+
FOR EACH ROW
|
|
350
|
+
WHEN NEW.content IS NOT OLD.content AND NEW.updated_at IS NOT datetime('now')
|
|
351
|
+
BEGIN
|
|
352
|
+
SELECT RAISE(ABORT, 'Refused: this UPDATE changes pads.content but leaves updated_at unchanged, which every hive pad tool (pad_write, pad_edit, pad_append) stamps in the same statement. To overwrite a large pad, use hive pad <name> --save <file>, which does this correctly. If you must run SQL directly, address the row BY PRIMARY KEY (id), never by name: pad names are unique per project, not globally, so a name-only WHERE clause matches every project''s pad with that name and silently overwrites the wrong project''s data.');
|
|
353
|
+
END;
|
|
354
|
+
`,
|
|
355
|
+
`
|
|
356
|
+
ALTER TABLE locks RENAME TO leases;
|
|
357
|
+
`,
|
|
358
|
+
`
|
|
359
|
+
DROP INDEX idx_timers_active;
|
|
360
|
+
DROP INDEX idx_timers_parent;
|
|
361
|
+
|
|
362
|
+
ALTER TABLE timers RENAME TO wakes;
|
|
363
|
+
ALTER TABLE wakes RENAME COLUMN parent_timer_id TO parent_wake_id;
|
|
364
|
+
ALTER TABLE wake_idle_notices RENAME COLUMN timer_id TO wake_id;
|
|
365
|
+
ALTER TABLE wake_idle_notices RENAME COLUMN notice_timer_id TO notice_wake_id;
|
|
366
|
+
ALTER TABLE wake_block_notices RENAME COLUMN timer_id TO wake_id;
|
|
367
|
+
|
|
368
|
+
CREATE INDEX idx_wakes_active ON wakes(project_id, kind)
|
|
369
|
+
WHERE cancelled_at IS NULL;
|
|
370
|
+
CREATE INDEX idx_wakes_parent ON wakes(parent_wake_id) WHERE parent_wake_id IS NOT NULL;
|
|
371
|
+
`,
|
|
372
|
+
];
|
|
373
|
+
function readAppliedVersions() {
|
|
374
|
+
return new Set(db.prepare("SELECT version FROM migrations").all().map((r) => r.version));
|
|
375
|
+
}
|
|
376
|
+
const MIGRATE_LOCK_MAX_ATTEMPTS = 24;
|
|
377
|
+
function retryOnBusy(fn, label) {
|
|
378
|
+
for (let attempt = 1;; attempt++) {
|
|
379
|
+
try {
|
|
380
|
+
return fn();
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
const busy = e instanceof Error && (e.code === "SQLITE_BUSY" || /database is locked/.test(e.message));
|
|
384
|
+
if (!busy || attempt >= MIGRATE_LOCK_MAX_ATTEMPTS) {
|
|
385
|
+
console.error(`hive: ${label} did not complete after ${attempt} attempt(s): the store's write lock never freed ` +
|
|
386
|
+
"up. Another hive process is likely still applying a migration (or is stuck); check for a " +
|
|
387
|
+
`wedged hive process and try again. Underlying error: ${errorMessage(e)}`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
export function migrate() {
|
|
394
|
+
retryOnBusy(() => {
|
|
395
|
+
db.exec(`CREATE TABLE IF NOT EXISTS migrations (
|
|
396
|
+
version INTEGER PRIMARY KEY,
|
|
397
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
398
|
+
)`);
|
|
399
|
+
db.exec(`CREATE TABLE IF NOT EXISTS backup_meta (
|
|
400
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
401
|
+
last_attempt_at TEXT,
|
|
402
|
+
last_success_at TEXT,
|
|
403
|
+
last_error TEXT,
|
|
404
|
+
last_error_at TEXT
|
|
405
|
+
)`);
|
|
406
|
+
db.prepare("INSERT OR IGNORE INTO backup_meta (id) VALUES (1)").run();
|
|
407
|
+
}, "preparing the store's bookkeeping tables");
|
|
408
|
+
const before = readAppliedVersions();
|
|
409
|
+
maybeBackupBeforeMigrations(db, dataDir, MIGRATIONS.length - before.size);
|
|
410
|
+
const applyPending = db.transaction(() => {
|
|
411
|
+
const applied = readAppliedVersions();
|
|
412
|
+
MIGRATIONS.forEach((sql, i) => {
|
|
413
|
+
const version = i + 1;
|
|
414
|
+
if (applied.has(version))
|
|
415
|
+
return;
|
|
416
|
+
db.exec(sql);
|
|
417
|
+
db.prepare("INSERT INTO migrations (version) VALUES (?)").run(version);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
retryOnBusy(() => applyPending.immediate(), "applying pending migrations");
|
|
421
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const DISPATCHER_MARKER = "# hive dispatcher";
|
|
7
|
+
export const dispatcherDir = () => process.env.HIVE_BIN_DIR || join(homedir(), ".local", "bin");
|
|
8
|
+
export const dispatcherPath = () => join(dispatcherDir(), "hive");
|
|
9
|
+
export const cliPath = () => fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
10
|
+
function shQuote(s) {
|
|
11
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
export function dispatcherScript(node, cli) {
|
|
14
|
+
return [
|
|
15
|
+
"#!/bin/sh",
|
|
16
|
+
DISPATCHER_MARKER,
|
|
17
|
+
"# Written by `hive setup`. It runs hive under one fixed interpreter, so a",
|
|
18
|
+
"# version manager resolving `node` per directory cannot land hive on a Node",
|
|
19
|
+
"# better-sqlite3's addon refuses to load under.",
|
|
20
|
+
"# Regenerate after every update: npm install && npm run build && hive setup",
|
|
21
|
+
`exec ${shQuote(node)} ${shQuote(cli)} "$@"`,
|
|
22
|
+
"",
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
export function readDispatcher(file) {
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = readFileSync(file, "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!text.includes(DISPATCHER_MARKER))
|
|
34
|
+
return { file, mine: false, node: null, cli: null };
|
|
35
|
+
const exec = /^exec '(.*)' '(.*)' "\$@"$/m.exec(text);
|
|
36
|
+
const unquote = (s) => s.replace(/'\\''/g, "'");
|
|
37
|
+
return exec
|
|
38
|
+
? { file, mine: true, node: unquote(exec[1]), cli: unquote(exec[2]) }
|
|
39
|
+
: { file, mine: true, node: null, cli: null };
|
|
40
|
+
}
|
|
41
|
+
const WORKTREE_PATH_FRAGMENTS = ["/.agents/worktrees/", "/.claude/worktrees/"];
|
|
42
|
+
function nearestGitEntry(startDir) {
|
|
43
|
+
let dir = startDir;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const gitPath = join(dir, ".git");
|
|
46
|
+
try {
|
|
47
|
+
return { path: gitPath, isFile: statSync(gitPath).isFile() };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
}
|
|
51
|
+
const parent = dirname(dir);
|
|
52
|
+
if (parent === dir)
|
|
53
|
+
return null;
|
|
54
|
+
dir = parent;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function gitdirTarget(gitFilePath) {
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = readFileSync(gitFilePath, "utf8");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(text);
|
|
66
|
+
return m ? m[1] : null;
|
|
67
|
+
}
|
|
68
|
+
function worktreeRoot(gitFilePath, target) {
|
|
69
|
+
const resolved = resolve(dirname(gitFilePath), target);
|
|
70
|
+
if (basename(dirname(resolved)) !== "worktrees" || basename(dirname(dirname(resolved))) !== ".git")
|
|
71
|
+
return null;
|
|
72
|
+
return dirname(dirname(dirname(resolved)));
|
|
73
|
+
}
|
|
74
|
+
export function linkedWorktreePin(cli) {
|
|
75
|
+
if (existsSync(cli)) {
|
|
76
|
+
const entry = nearestGitEntry(dirname(cli));
|
|
77
|
+
if (entry?.isFile) {
|
|
78
|
+
const target = gitdirTarget(entry.path);
|
|
79
|
+
const root = target ? worktreeRoot(entry.path, target) : null;
|
|
80
|
+
if (root)
|
|
81
|
+
return { linked: true, via: "git-file", durableRoot: root };
|
|
82
|
+
}
|
|
83
|
+
return { linked: false, via: null, durableRoot: null };
|
|
84
|
+
}
|
|
85
|
+
const fragment = WORKTREE_PATH_FRAGMENTS.some((f) => cli.includes(f));
|
|
86
|
+
return fragment
|
|
87
|
+
? { linked: true, via: "path-fragment", durableRoot: null }
|
|
88
|
+
: { linked: false, via: null, durableRoot: null };
|
|
89
|
+
}
|
|
90
|
+
export function firstHiveOnPath() {
|
|
91
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
92
|
+
if (!dir)
|
|
93
|
+
continue;
|
|
94
|
+
const candidate = join(dir, "hive");
|
|
95
|
+
try {
|
|
96
|
+
if (statSync(candidate).mode & 0o111)
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const VERSION_MANAGER_DIRS = [
|
|
105
|
+
["/.asdf/", "asdf"],
|
|
106
|
+
["/.nvm/", "nvm"],
|
|
107
|
+
["/.volta/", "volta"],
|
|
108
|
+
["/.fnm/", "fnm"],
|
|
109
|
+
["/fnm_multishells/", "fnm"],
|
|
110
|
+
["/mise/installs/", "mise"],
|
|
111
|
+
["/Herd/config/nvm/", "Herd"],
|
|
112
|
+
["/n/versions/", "n"],
|
|
113
|
+
];
|
|
114
|
+
export function versionManagerOwning(nodePath) {
|
|
115
|
+
return VERSION_MANAGER_DIRS.find(([fragment]) => nodePath.includes(fragment))?.[1] ?? null;
|
|
116
|
+
}
|
|
117
|
+
export function durabilityLines(node) {
|
|
118
|
+
const manager = versionManagerOwning(node);
|
|
119
|
+
if (manager) {
|
|
120
|
+
return [
|
|
121
|
+
`! This Node lives inside ${manager}'s install directory. The pin is stable until`,
|
|
122
|
+
` that version is removed: uninstalling it through ${manager} later breaks the`,
|
|
123
|
+
" dispatcher with a confusing exec error. A Homebrew or system Node cannot be",
|
|
124
|
+
" removed that way.",
|
|
125
|
+
" To pin a different one, run setup WITH it - setup pins whatever Node",
|
|
126
|
+
" runs it, so naming it is the whole instruction:",
|
|
127
|
+
` /opt/homebrew/bin/node "${cliPath()}" setup`,
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
return [
|
|
131
|
+
"This Node is not in any install directory hive recognizes as a version",
|
|
132
|
+
"manager's, so it is probably yours to keep. Check before relying on that.",
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
export function pathAdvice(dir, file) {
|
|
136
|
+
const first = firstHiveOnPath();
|
|
137
|
+
if (first === file)
|
|
138
|
+
return [`${dir} comes first on PATH, so \`hive\` runs the pinned interpreter.`];
|
|
139
|
+
if (!(process.env.PATH ?? "").split(":").includes(dir)) {
|
|
140
|
+
return [
|
|
141
|
+
`${dir} is not on PATH. Add it AHEAD of your version manager's shims,`,
|
|
142
|
+
"which usually prepend themselves:",
|
|
143
|
+
` export PATH="${dir}:$PATH"`,
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
return [
|
|
147
|
+
`${first} comes first on PATH, so that one wins instead (npm link writes one).`,
|
|
148
|
+
` Put ${dir} ahead of it: export PATH="${dir}:$PATH"`,
|
|
149
|
+
];
|
|
150
|
+
}
|