@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,131 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { nodeRangeForNodeApi, requiredNodeApi } from "./abi.js";
|
|
5
|
+
import { cliPath, dispatcherPath, readDispatcher } from "./dispatcher.js";
|
|
6
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
7
|
+
const probeEntry = () => fileURLToPath(new URL("./abiProbe.js", import.meta.url));
|
|
8
|
+
export function reexecTarget() {
|
|
9
|
+
const path = readDispatcher(dispatcherPath())?.node ?? null;
|
|
10
|
+
if (path === null)
|
|
11
|
+
return null;
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return { path, state: "gone" };
|
|
14
|
+
const probe = probeInterpreter(path);
|
|
15
|
+
if (!probe.probed)
|
|
16
|
+
return { path, state: "unverified", detail: probe.detail };
|
|
17
|
+
return probe.ok ? { path, state: "loads" } : { path, state: "cannot", detail: probe.detail };
|
|
18
|
+
}
|
|
19
|
+
export function probeSessionInterpreter(dir) {
|
|
20
|
+
return runProbe("node", dir);
|
|
21
|
+
}
|
|
22
|
+
export function probeInterpreter(node) {
|
|
23
|
+
return runProbe(node, undefined);
|
|
24
|
+
}
|
|
25
|
+
function runProbe(command, cwd) {
|
|
26
|
+
const unprobed = (detail) => ({ probed: false, detail });
|
|
27
|
+
const result = spawnSync(command, [probeEntry()], {
|
|
28
|
+
cwd,
|
|
29
|
+
env: process.env,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
32
|
+
killSignal: "SIGKILL",
|
|
33
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
34
|
+
});
|
|
35
|
+
if (result.error)
|
|
36
|
+
return unprobed(`could not run \`${command}\`: ${result.error.message}`);
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(result.stdout.trim());
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
const noise = (result.stderr || result.stdout).trim().split("\n")[0] ?? "";
|
|
43
|
+
return unprobed(`\`${command}\` did not answer the probe (exit ${result.status ?? "killed"})` +
|
|
44
|
+
(noise ? `: ${noise.slice(0, 200)}` : ""));
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
probed: true,
|
|
48
|
+
ok: parsed.ok,
|
|
49
|
+
execPath: parsed.execPath,
|
|
50
|
+
version: parsed.version,
|
|
51
|
+
detail: parsed.detail,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function sessionStartVerdict(probe, target) {
|
|
55
|
+
if (!probe.probed) {
|
|
56
|
+
return {
|
|
57
|
+
level: "warn",
|
|
58
|
+
lines: [
|
|
59
|
+
"hive cannot say whether a session starting there would work.",
|
|
60
|
+
probe.detail,
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const runs = `a session there runs ${probe.execPath} (${probe.version})`;
|
|
65
|
+
if (probe.ok)
|
|
66
|
+
return { level: "info", lines: [`${runs}, which loads hive's addon`] };
|
|
67
|
+
const cannot = [`${runs}, which CANNOT load hive's addon.`, probe.detail];
|
|
68
|
+
const hookOnly = [
|
|
69
|
+
"Either way that is the hook only. A bare `node` in hive's MCP registration resolves per",
|
|
70
|
+
"directory as well, and nothing re-execs that; doctor reports it separately.",
|
|
71
|
+
];
|
|
72
|
+
const pinned = target();
|
|
73
|
+
if (pinned !== null && pinned.state !== "gone" && pinned.path !== probe.execPath) {
|
|
74
|
+
if (pinned.state === "loads") {
|
|
75
|
+
return {
|
|
76
|
+
level: "warn",
|
|
77
|
+
lines: [
|
|
78
|
+
...cannot,
|
|
79
|
+
"The SessionStart hook survives this by re-execing into the interpreter the dispatcher",
|
|
80
|
+
`pins, ${pinned.path}, and hive loaded the addon under that interpreter to check.`,
|
|
81
|
+
"If a version manager retires it, a session here loses hive's session-start digest and",
|
|
82
|
+
"prints the addon banner instead, naming the Node rather than this project.",
|
|
83
|
+
...hookOnly,
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (pinned.state === "cannot") {
|
|
88
|
+
return {
|
|
89
|
+
level: "warn",
|
|
90
|
+
lines: [
|
|
91
|
+
...cannot,
|
|
92
|
+
`The SessionStart hook re-execs into ${pinned.path}, which CANNOT load the addon either:`,
|
|
93
|
+
pinned.detail,
|
|
94
|
+
"So the re-exec does not rescue this: a session starting there prints the addon banner",
|
|
95
|
+
"after re-execing, and nothing in that banner names this project or that pin.",
|
|
96
|
+
...hookOnly,
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
level: "warn",
|
|
102
|
+
lines: [
|
|
103
|
+
...cannot,
|
|
104
|
+
`The SessionStart hook would re-exec into ${pinned.path}, but hive could not verify that`,
|
|
105
|
+
`interpreter, so whether that rescues a session here is unknown: ${pinned.detail}`,
|
|
106
|
+
...hookOnly,
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const why = pinned === null
|
|
111
|
+
? ["There is no dispatcher at all, so the SessionStart hook has nothing to re-exec into."]
|
|
112
|
+
: pinned.state === "gone"
|
|
113
|
+
? [`The dispatcher pins ${pinned.path}, which is not on disk, so the SessionStart hook has`, "nothing to re-exec into."]
|
|
114
|
+
: ["The dispatcher pins this same interpreter, so re-execing into it would change nothing."];
|
|
115
|
+
return {
|
|
116
|
+
level: "warn",
|
|
117
|
+
lines: [
|
|
118
|
+
...cannot,
|
|
119
|
+
...why,
|
|
120
|
+
"A session starting there gets the addon banner instead of hive's session-start digest.",
|
|
121
|
+
"Give the hook something to re-exec into, naming the interpreter (setup pins whatever Node",
|
|
122
|
+
"runs it, so the way out always names one):",
|
|
123
|
+
` <a Node matching ${nodeRange()}> "${cliPath()}" setup`,
|
|
124
|
+
...hookOnly,
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function nodeRange() {
|
|
129
|
+
const need = requiredNodeApi();
|
|
130
|
+
return (need === null ? null : nodeRangeForNodeApi(need)) ?? "that Node-API level";
|
|
131
|
+
}
|
package/dist/slug.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const SLUG_MAX_LEN = 40;
|
|
2
|
+
const CONTROL_CHARS_RE = /[\x00-\x1F\x7F]+/g;
|
|
3
|
+
function stripControlChars(text) {
|
|
4
|
+
return text.replace(CONTROL_CHARS_RE, " ");
|
|
5
|
+
}
|
|
6
|
+
const ELLIPSIS = "…";
|
|
7
|
+
const CUT_BUDGET = SLUG_MAX_LEN - 1;
|
|
8
|
+
export function cutToUnitBudget(text, budget) {
|
|
9
|
+
let cut = "";
|
|
10
|
+
for (const ch of text) {
|
|
11
|
+
if (cut.length + ch.length > budget)
|
|
12
|
+
break;
|
|
13
|
+
cut += ch;
|
|
14
|
+
}
|
|
15
|
+
return cut;
|
|
16
|
+
}
|
|
17
|
+
export function fallbackSlug(title) {
|
|
18
|
+
const trimmed = stripControlChars(title).trim();
|
|
19
|
+
if (trimmed.length <= SLUG_MAX_LEN)
|
|
20
|
+
return trimmed;
|
|
21
|
+
const cut = cutToUnitBudget(trimmed, CUT_BUDGET);
|
|
22
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
23
|
+
return (lastSpace > CUT_BUDGET / 2 ? cut.slice(0, lastSpace) : cut) + ELLIPSIS;
|
|
24
|
+
}
|
|
25
|
+
// Every field interpolated into text that hive types into a pane goes through here. A raw control byte
|
|
26
|
+
// reaches tmux as a keystroke rather than as text, so a \r in an interpolated name or body submits the
|
|
27
|
+
// prompt early and splits the message; flattening half a string is worse than none, because the next
|
|
28
|
+
// reader stops looking. Wider than stripControlChars above, which is the slug path and stays as it is.
|
|
29
|
+
export const flatten = (text) => text.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim();
|
package/dist/spawn.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { preservedRolloutPath, reapCodexHome } from "./codexHome.js";
|
|
4
|
+
import { getProject } from "./context.js";
|
|
5
|
+
import { dataDir, db } from "./db.js";
|
|
6
|
+
import { loadProjectYml } from "./projectYml.js";
|
|
7
|
+
import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, panePid, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, } from "./tmux.js";
|
|
8
|
+
export function withWindowClaim(claim) {
|
|
9
|
+
if (db.inTransaction) {
|
|
10
|
+
throw new Error("withWindowClaim was called from inside an already-open transaction. better-sqlite3 nests a " +
|
|
11
|
+
"transaction inside another one as a no-op SAVEPOINT rather than throwing, so this call would " +
|
|
12
|
+
"take no writer slot of its own and the machine-wide window-claim exclusion would silently " +
|
|
13
|
+
"disappear - no error, no failing test, just the read-then-create race back. Move the outer " +
|
|
14
|
+
"transaction so it does not wrap this call, or move the work it does inside this claim instead: " +
|
|
15
|
+
"withWindowClaim must be the outermost transaction on its call stack.");
|
|
16
|
+
}
|
|
17
|
+
return db.transaction(claim).immediate();
|
|
18
|
+
}
|
|
19
|
+
export function splitTargetWindow(session, projectId, parentActor) {
|
|
20
|
+
const parent = db
|
|
21
|
+
.prepare("SELECT tmux_target, tmux_socket FROM agents WHERE actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
|
|
22
|
+
.get(parentActor);
|
|
23
|
+
if (parent && rowLive(parent.tmux_socket, parent.tmux_target) === true) {
|
|
24
|
+
const window = paneWindow(parent.tmux_target);
|
|
25
|
+
if (window)
|
|
26
|
+
return `${session}:${window.split(":")[1]}`;
|
|
27
|
+
}
|
|
28
|
+
return findProjectWindow(session, projectId) ?? null;
|
|
29
|
+
}
|
|
30
|
+
export function buildEnvFlags(env) {
|
|
31
|
+
return Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
32
|
+
}
|
|
33
|
+
function agentIdentityEnv(actorId, name, projectPath) {
|
|
34
|
+
return {
|
|
35
|
+
HIVE_AGENT_ID: actorId,
|
|
36
|
+
HIVE_AGENT_NAME: name,
|
|
37
|
+
HIVE_PROJECT_LOCK: "1",
|
|
38
|
+
HIVE_PROJECT_PATH: projectPath,
|
|
39
|
+
HIVE_DATA_DIR: dataDir,
|
|
40
|
+
HIVE_LEAD: "",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function recordPane(agentId, target, socket) {
|
|
44
|
+
return (db
|
|
45
|
+
.prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ? WHERE id = ? AND status = 'running'")
|
|
46
|
+
.run(target, socket, panePid(target), agentId).changes > 0);
|
|
47
|
+
}
|
|
48
|
+
// Two callers, one meaning: whatever is left in the window re-tiles to the layout it was arranged
|
|
49
|
+
// with. agent_close kills a worker's pane through this; stopProcess (src/processes.ts) is the only
|
|
50
|
+
// thing that kills a command's, and its graceful leg loses the pane without a kill at all - which is
|
|
51
|
+
// why the re-tile is reachable on its own.
|
|
52
|
+
export function relayoutAfterPaneLeft(window) {
|
|
53
|
+
if (!window)
|
|
54
|
+
return;
|
|
55
|
+
const ownerId = windowOwner(window);
|
|
56
|
+
const ownerProject = ownerId != null ? getProject(ownerId) : undefined;
|
|
57
|
+
applyLayout(window, windowLayout(window) ?? (ownerProject ? loadProjectYml(ownerProject.path).config?.layout : undefined) ?? DEFAULT_LAYOUT);
|
|
58
|
+
}
|
|
59
|
+
export function killAgentPane(target) {
|
|
60
|
+
const pane = isPaneTarget(target);
|
|
61
|
+
const window = pane ? paneWindow(target) : null;
|
|
62
|
+
tmux(pane ? "kill-pane" : "kill-window", "-t", target);
|
|
63
|
+
relayoutAfterPaneLeft(window);
|
|
64
|
+
}
|
|
65
|
+
export function discardOrphanedPane(target) {
|
|
66
|
+
try {
|
|
67
|
+
waitForPaneEstablished(target);
|
|
68
|
+
tmux("kill-pane", "-t", target);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const paneRacedRetirement = (agentId) => new Error(`Agent ${agentId}'s row was retired (closed or parked) while its pane was being created, so the pane was ` +
|
|
74
|
+
"discarded rather than recorded against a row that is no longer running. Nothing is left running for it. " +
|
|
75
|
+
"Re-read the row with agent_status and resume or spawn again if that was not what you intended.");
|
|
76
|
+
export function asNameClash(e, name) {
|
|
77
|
+
const err = e;
|
|
78
|
+
const message = err.message ?? "";
|
|
79
|
+
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" &&
|
|
80
|
+
(message.includes("agents.name") || message.includes("idx_agents_running_name"))) {
|
|
81
|
+
if (name === LEAD_NAME) {
|
|
82
|
+
return new Error("Another `hive lead` won the race to start this project's lead session. Re-run `hive lead`; " +
|
|
83
|
+
"it will find and reuse the row that invocation just created.");
|
|
84
|
+
}
|
|
85
|
+
return new Error(`A running agent named "${name}" already exists. Pick another name.`);
|
|
86
|
+
}
|
|
87
|
+
return e;
|
|
88
|
+
}
|
|
89
|
+
export const LEAD_KIND = "lead";
|
|
90
|
+
export const LEAD_ACTOR_PREFIX = `${LEAD_KIND}:`;
|
|
91
|
+
export const isLeadActorId = (actorId) => actorId.startsWith(LEAD_ACTOR_PREFIX);
|
|
92
|
+
export const mintLeadActorId = (agentId) => `${LEAD_ACTOR_PREFIX}${agentId}`;
|
|
93
|
+
export const isRunningLeadActor = (actorId) => !!db.prepare("SELECT 1 FROM agents WHERE actor_id = ? AND kind = ? AND status = 'running'").get(actorId, LEAD_KIND);
|
|
94
|
+
export const LEAD_NAME = LEAD_KIND;
|
|
95
|
+
export const isReservedAgentName = (name) => name.toLowerCase() === LEAD_NAME;
|
|
96
|
+
export function upsertActor(actorId, name, kind) {
|
|
97
|
+
db.prepare(`INSERT INTO actors (id, name, kind) VALUES (?, ?, ?)
|
|
98
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, last_seen_at = datetime('now')`).run(actorId, name, kind);
|
|
99
|
+
}
|
|
100
|
+
function placeAgentPane(session, spec, envFlags, commandString, title) {
|
|
101
|
+
let landedInProjectId = null;
|
|
102
|
+
let layoutApplied = false;
|
|
103
|
+
let inProcessesWindow = false;
|
|
104
|
+
const target = withWindowClaim(() => {
|
|
105
|
+
const windowName = spec.placement === "split" ? spec.projectName : title;
|
|
106
|
+
const windowOwnerId = spec.placement === "split" ? spec.projectId : null;
|
|
107
|
+
const started = ensureSession(session, spec.cwd, { envFlags, command: commandString });
|
|
108
|
+
if (started.created) {
|
|
109
|
+
// Deliberate for placement "processes" too: this pane IS the new session's initial window, so it
|
|
110
|
+
// takes that window rather than a tile. The caller reports it; `hive hide` moves it in later.
|
|
111
|
+
return claimInitialWindow(started, windowName, windowOwnerId).pane;
|
|
112
|
+
}
|
|
113
|
+
const splitInto = (window, layout) => {
|
|
114
|
+
const pane = tmux("split-window", "-d", "-P", "-F", "#{pane_id}", "-t", window, "-c", spec.cwd, ...envFlags, commandString, ...(spec.retainOnExit ? retainOnExitArgs(window) : []));
|
|
115
|
+
applyLayout(window, layout);
|
|
116
|
+
layoutApplied = true;
|
|
117
|
+
return pane;
|
|
118
|
+
};
|
|
119
|
+
if (spec.placement === "processes") {
|
|
120
|
+
let pane;
|
|
121
|
+
const existing = findProcessesWindow(session, spec.projectId);
|
|
122
|
+
if (existing) {
|
|
123
|
+
pane = splitInto(existing, PROCESSES_LAYOUT);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const made = createWindow(session, processesWindowName(spec.projectName), spec.cwd, envFlags, commandString, null, true, spec.retainOnExit ?? false);
|
|
127
|
+
makeProcessesWindow(made.window, spec.projectId);
|
|
128
|
+
pane = made.pane;
|
|
129
|
+
}
|
|
130
|
+
setPaneTitle(pane, processesPaneTitle(spec.projectName, spec.name));
|
|
131
|
+
inProcessesWindow = true;
|
|
132
|
+
return pane;
|
|
133
|
+
}
|
|
134
|
+
if (spec.placement === "split") {
|
|
135
|
+
const found = splitTargetWindow(session, spec.projectId, spec.parentActor);
|
|
136
|
+
if (found) {
|
|
137
|
+
const pane = splitInto(found, spec.layout ?? DEFAULT_LAYOUT);
|
|
138
|
+
const owner = windowOwner(found);
|
|
139
|
+
if (owner !== null && owner !== spec.projectId)
|
|
140
|
+
landedInProjectId = owner;
|
|
141
|
+
return pane;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return createWindow(session, windowName, spec.cwd, envFlags, commandString, windowOwnerId, true, spec.retainOnExit ?? false).pane;
|
|
145
|
+
});
|
|
146
|
+
return { target, landedInProjectId, layoutApplied, inProcessesWindow };
|
|
147
|
+
}
|
|
148
|
+
export function launchAgent(spec) {
|
|
149
|
+
if (untrustedTmuxServer())
|
|
150
|
+
throw crossServerRefusal("spawn");
|
|
151
|
+
const socket = tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR);
|
|
152
|
+
let info;
|
|
153
|
+
try {
|
|
154
|
+
info = db
|
|
155
|
+
.prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_socket, session_id, codex_home) " +
|
|
156
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
157
|
+
.run(spec.projectId, spec.name, typeof spec.commandString === "string" ? spec.commandString : "", spec.cwd, spec.kind, spec.parentActor, socket, spec.sessionId ?? "", spec.codexHome ?? "");
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
throw asNameClash(e, spec.name);
|
|
161
|
+
}
|
|
162
|
+
const agentId = Number(info.lastInsertRowid);
|
|
163
|
+
const actorId = `${spec.kind}:${agentId}`;
|
|
164
|
+
let paneUp = false;
|
|
165
|
+
try {
|
|
166
|
+
const commandString = typeof spec.commandString === "string" ? spec.commandString : spec.commandString({ agentId, actorId });
|
|
167
|
+
db.prepare("UPDATE agents SET actor_id = ?, command = ? WHERE id = ?").run(actorId, commandString, agentId);
|
|
168
|
+
upsertActor(actorId, spec.name, spec.kind);
|
|
169
|
+
const session = sessionName();
|
|
170
|
+
const env = spec.kind === "agent"
|
|
171
|
+
? { ...spec.env, ...agentIdentityEnv(actorId, spec.name, spec.projectPath) }
|
|
172
|
+
: spec.env;
|
|
173
|
+
const envFlags = buildEnvFlags(env);
|
|
174
|
+
const title = windowTitle(spec.projectName, spec.name);
|
|
175
|
+
const { target, landedInProjectId, layoutApplied, inProcessesWindow } = placeAgentPane(session, spec, envFlags, commandString, title);
|
|
176
|
+
paneUp = true;
|
|
177
|
+
if (!recordPane(agentId, target, socket)) {
|
|
178
|
+
discardOrphanedPane(target);
|
|
179
|
+
throw paneRacedRetirement(agentId);
|
|
180
|
+
}
|
|
181
|
+
return { agentId, actorId, target, landedInProjectId, layoutApplied, inProcessesWindow };
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
if (paneUp)
|
|
185
|
+
throw e;
|
|
186
|
+
db.prepare("DELETE FROM agents WHERE id = ?").run(agentId);
|
|
187
|
+
throw e;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export const RESUME_FLIP_COLUMNS = [
|
|
191
|
+
"status",
|
|
192
|
+
"closed_at",
|
|
193
|
+
"tmux_target",
|
|
194
|
+
"pane_pid",
|
|
195
|
+
"agent_state",
|
|
196
|
+
"state_changed_at",
|
|
197
|
+
"parked_at",
|
|
198
|
+
"parked_branch",
|
|
199
|
+
"resumed_at",
|
|
200
|
+
"tmux_socket",
|
|
201
|
+
"command",
|
|
202
|
+
];
|
|
203
|
+
export function resumeFlipSql() {
|
|
204
|
+
return ("UPDATE agents SET status = 'running', closed_at = NULL, tmux_target = '', pane_pid = '', " +
|
|
205
|
+
`agent_state = 'unknown', state_changed_at = NULL, ${PARK_STAMP_CLEARED}, ` +
|
|
206
|
+
"resumed_at = datetime('now'), tmux_socket = ?, command = ? " +
|
|
207
|
+
"WHERE id = ? AND status = 'closed'");
|
|
208
|
+
}
|
|
209
|
+
function restoreFlippedRow(agentId, before) {
|
|
210
|
+
db.prepare(`UPDATE agents SET ${RESUME_FLIP_COLUMNS.map((c) => `${c} = ?`).join(", ")} ` +
|
|
211
|
+
"WHERE id = ? AND status = 'running' AND tmux_target = ''").run(...RESUME_FLIP_COLUMNS.map((c) => before[c] ?? null), agentId);
|
|
212
|
+
}
|
|
213
|
+
export function resumeAgent(spec) {
|
|
214
|
+
if (untrustedTmuxServer())
|
|
215
|
+
throw crossServerRefusal("resume");
|
|
216
|
+
const socket = tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR);
|
|
217
|
+
let flip;
|
|
218
|
+
const captureBeforeFlip = db.prepare(`SELECT ${RESUME_FLIP_COLUMNS.join(", ")} FROM agents WHERE id = ?`);
|
|
219
|
+
const flipStatement = db.prepare(resumeFlipSql());
|
|
220
|
+
try {
|
|
221
|
+
flip = db
|
|
222
|
+
.transaction(() => ({
|
|
223
|
+
before: captureBeforeFlip.get(spec.agentId),
|
|
224
|
+
changes: flipStatement.run(socket, spec.commandString, spec.agentId).changes,
|
|
225
|
+
}))
|
|
226
|
+
.immediate();
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
const err = e;
|
|
230
|
+
const message = err.message ?? "";
|
|
231
|
+
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" &&
|
|
232
|
+
(message.includes("agents.name") || message.includes("idx_agents_running_name"))) {
|
|
233
|
+
throw new Error(`Cannot resume agent ${spec.agentId}: a running agent already has the name "${spec.name}". Rename or ` +
|
|
234
|
+
"close that one first, or resume a different agent_id.");
|
|
235
|
+
}
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
238
|
+
if (flip.changes === 0) {
|
|
239
|
+
throw new Error(`Agent ${spec.agentId} is not closed - another caller may have resumed it first.`);
|
|
240
|
+
}
|
|
241
|
+
let paneUp = false;
|
|
242
|
+
try {
|
|
243
|
+
upsertActor(spec.actorId, spec.name, "agent");
|
|
244
|
+
const envFlags = buildEnvFlags({
|
|
245
|
+
...spec.env,
|
|
246
|
+
...agentIdentityEnv(spec.actorId, spec.name, spec.projectPath),
|
|
247
|
+
});
|
|
248
|
+
const session = sessionName();
|
|
249
|
+
const title = windowTitle(spec.projectName, spec.name);
|
|
250
|
+
const { target, landedInProjectId } = placeAgentPane(session, spec, envFlags, spec.commandString, title);
|
|
251
|
+
paneUp = true;
|
|
252
|
+
if (!recordPane(spec.agentId, target, socket)) {
|
|
253
|
+
discardOrphanedPane(target);
|
|
254
|
+
throw paneRacedRetirement(spec.agentId);
|
|
255
|
+
}
|
|
256
|
+
return { target, landedInProjectId };
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
if (!paneUp) {
|
|
260
|
+
if (flip.before)
|
|
261
|
+
restoreFlippedRow(spec.agentId, flip.before);
|
|
262
|
+
else
|
|
263
|
+
closeAgentRow(spec.agentId);
|
|
264
|
+
}
|
|
265
|
+
throw e;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function ownsItsWindow(target, expectedTitle) {
|
|
269
|
+
try {
|
|
270
|
+
const [name, projectStamp, owned] = tmux("list-panes", "-t", target, "-F", "#{window_name}\t#{@hive-project-id}\t#{@hive-owned}")
|
|
271
|
+
.split("\n")[0]
|
|
272
|
+
.split("\t");
|
|
273
|
+
return name === expectedTitle && (projectStamp ?? "") === "" && owned === "1";
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
export function renameAgent(agent, newName, projectName) {
|
|
280
|
+
try {
|
|
281
|
+
db.transaction(() => {
|
|
282
|
+
db.prepare("UPDATE agents SET name = ? WHERE id = ?").run(newName, agent.id);
|
|
283
|
+
db.prepare("UPDATE actors SET name = ? WHERE id = ?").run(newName, agent.actor_id);
|
|
284
|
+
})();
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
throw asNameClash(e, newName);
|
|
288
|
+
}
|
|
289
|
+
if (projectName !== null && ownsItsWindow(agent.tmux_target, windowTitle(projectName, agent.name))) {
|
|
290
|
+
try {
|
|
291
|
+
tmux("rename-window", "-t", agent.tmux_target, windowTitle(projectName, newName));
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export function branchAt(cwd) {
|
|
298
|
+
const git = (args) => {
|
|
299
|
+
try {
|
|
300
|
+
return execFileSync("git", args, {
|
|
301
|
+
cwd,
|
|
302
|
+
encoding: "utf8",
|
|
303
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
304
|
+
timeout: 2000,
|
|
305
|
+
}).trim();
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
312
|
+
if (branch === null)
|
|
313
|
+
return "";
|
|
314
|
+
if (branch !== "HEAD")
|
|
315
|
+
return branch;
|
|
316
|
+
return git(["rev-parse", "--short", "HEAD"]) ?? "";
|
|
317
|
+
}
|
|
318
|
+
export function parkAgentRow(agentId, expectedTmuxTarget, branch) {
|
|
319
|
+
return db
|
|
320
|
+
.prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now'), parked_at = datetime('now'), " +
|
|
321
|
+
"parked_branch = ? WHERE id = ? AND status = 'running' AND tmux_target = ? RETURNING parked_at")
|
|
322
|
+
.get(branch, agentId, expectedTmuxTarget)?.parked_at;
|
|
323
|
+
}
|
|
324
|
+
export const PARK_STAMP_CLEARED = "parked_at = '', parked_branch = ''";
|
|
325
|
+
export function releaseParkRow(agentId) {
|
|
326
|
+
return (db
|
|
327
|
+
.prepare(`UPDATE agents SET ${PARK_STAMP_CLEARED} WHERE id = ? AND status = 'closed' AND parked_at != ''`)
|
|
328
|
+
.run(agentId).changes > 0);
|
|
329
|
+
}
|
|
330
|
+
// Row status alone is not the orphan test: a PARKED row is `status = 'closed'` too
|
|
331
|
+
// (parkAgentRow above), and agent_park expects to resume it, cold-cache and all. `parked_at = ''`
|
|
332
|
+
// is named explicitly rather than left to fall out of "closed" incidentally: codex can now be
|
|
333
|
+
// parked too (harnessFor("codex").supportsResume, todo 563), exactly as this predicate already
|
|
334
|
+
// assumed some non-claude harness eventually would - deriving the exclusion from harness capability
|
|
335
|
+
// instead would have made this reap query's correctness depend on a table this file does not own.
|
|
336
|
+
//
|
|
337
|
+
// No conditional-UPDATE claim guards this, unlike closeAgentRow's "the conditional UPDATE is the
|
|
338
|
+
// claim" - a deliberate difference, not a gap. That pattern earns its keep against an OBSERVABLE
|
|
339
|
+
// side effect: a wake fired twice types into a pane twice. A reap has none - two instances racing
|
|
340
|
+
// the same directory produce one deleted directory (rmSync's force:true swallows ENOENT even mid
|
|
341
|
+
// recursive delete, not just on the top-level path), and the loser's `codex_home` update just
|
|
342
|
+
// matches zero rows. Deleted twice is deleted once, so there is nothing here for a claim to arbitrate.
|
|
343
|
+
//
|
|
344
|
+
// rmSync runs before the clear, not after: if it throws (permissions, a mid-delete race), codex_home
|
|
345
|
+
// stays set and this row is picked up again - by the next agent_close call, or by the janitor's own
|
|
346
|
+
// backstop sweep - rather than the failure being silently swallowed by a claim that already fired.
|
|
347
|
+
// The column IS the retry token; that is the guard here, it is just not shaped like a CAS.
|
|
348
|
+
// Both callers wrap this, so a failure here costs a retry, never the close or the sweep it runs inside.
|
|
349
|
+
// The target comes from the row's own transcript_path, not from reapCodexHome's returned list, so
|
|
350
|
+
// a retry and a racing reap converge on the same file; the rewrite runs before the codex_home clear.
|
|
351
|
+
export function reapCodexHomeForClosedAgent(agentId, codexHome) {
|
|
352
|
+
const before = db.prepare("SELECT transcript_path FROM agents WHERE id = ?").get(agentId);
|
|
353
|
+
const oldTranscriptPath = before?.transcript_path ?? "";
|
|
354
|
+
const dest = oldTranscriptPath ? preservedRolloutPath(codexHome, oldTranscriptPath) : undefined;
|
|
355
|
+
reapCodexHome(codexHome);
|
|
356
|
+
if (dest && existsSync(dest)) {
|
|
357
|
+
db.prepare("UPDATE agents SET transcript_path = ? WHERE id = ? AND transcript_path = ?").run(dest, agentId, oldTranscriptPath);
|
|
358
|
+
}
|
|
359
|
+
db.prepare("UPDATE agents SET codex_home = '' WHERE id = ? AND codex_home = ?").run(agentId, codexHome);
|
|
360
|
+
}
|
|
361
|
+
export function closeAgentRow(agentId, expectedTmuxTarget) {
|
|
362
|
+
const info = expectedTmuxTarget === undefined
|
|
363
|
+
? db
|
|
364
|
+
.prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running'")
|
|
365
|
+
.run(agentId)
|
|
366
|
+
: db
|
|
367
|
+
.prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' AND tmux_target = ?")
|
|
368
|
+
.run(agentId, expectedTmuxTarget);
|
|
369
|
+
return info.changes > 0;
|
|
370
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { awaitingFirstPrompt } from "./firstPrompt.js";
|
|
3
|
+
import { harnessFor } from "./harnesses.js";
|
|
4
|
+
import { sanitizeEventForDisplay } from "./tmux.js";
|
|
5
|
+
const prepared = new Map();
|
|
6
|
+
function stmt(sql) {
|
|
7
|
+
let s = prepared.get(sql);
|
|
8
|
+
if (!s) {
|
|
9
|
+
s = db.prepare(sql);
|
|
10
|
+
prepared.set(sql, s);
|
|
11
|
+
}
|
|
12
|
+
return s;
|
|
13
|
+
}
|
|
14
|
+
function parseStoreTimestamp(ts) {
|
|
15
|
+
return new Date(`${ts.replace(" ", "T")}Z`).getTime();
|
|
16
|
+
}
|
|
17
|
+
export function ageSecondsSince(storeTimestamp, now = Date.now()) {
|
|
18
|
+
return Math.max(0, Math.round((now - parseStoreTimestamp(storeTimestamp)) / 1000));
|
|
19
|
+
}
|
|
20
|
+
export function humanizeAge(seconds) {
|
|
21
|
+
if (seconds < 60)
|
|
22
|
+
return `${seconds}s`;
|
|
23
|
+
if (seconds < 3600)
|
|
24
|
+
return `${Math.floor(seconds / 60)}m`;
|
|
25
|
+
return `${Math.floor(seconds / 3600)}h`;
|
|
26
|
+
}
|
|
27
|
+
export function deriveProvenance(row, alive, now = Date.now()) {
|
|
28
|
+
const lastSeenRow = stmt("SELECT last_seen_at FROM actors WHERE id = ?").get(row.actor_id);
|
|
29
|
+
const last_seen = lastSeenRow?.last_seen_at ?? null;
|
|
30
|
+
if (alive === false) {
|
|
31
|
+
return { state: "gone", source: "tmux-probe", event: null, since: null, age_seconds: null, last_seen };
|
|
32
|
+
}
|
|
33
|
+
if (!reportsAgentStateLog(row)) {
|
|
34
|
+
return {
|
|
35
|
+
state: row.agent_state,
|
|
36
|
+
source: "not-instrumented",
|
|
37
|
+
event: null,
|
|
38
|
+
since: null,
|
|
39
|
+
age_seconds: null,
|
|
40
|
+
last_seen,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const since = row.state_changed_at;
|
|
44
|
+
const age_seconds = since ? ageSecondsSince(since, now) : null;
|
|
45
|
+
const logRow = since
|
|
46
|
+
? stmt("SELECT event FROM agent_state_log WHERE actor_id = ? AND state = ? ORDER BY id DESC LIMIT 1").get(row.actor_id, row.agent_state)
|
|
47
|
+
: undefined;
|
|
48
|
+
return {
|
|
49
|
+
state: row.agent_state,
|
|
50
|
+
source: logRow ? "hook" : "no-record",
|
|
51
|
+
event: logRow ? logRow.event : null,
|
|
52
|
+
since,
|
|
53
|
+
age_seconds,
|
|
54
|
+
last_seen,
|
|
55
|
+
...(awaitingFirstPrompt(row) ? { awaiting_first_prompt: true } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function describeForHuman(prov, now = Date.now()) {
|
|
59
|
+
if (prov.awaiting_first_prompt && prov.state === "idle") {
|
|
60
|
+
return prov.age_seconds == null
|
|
61
|
+
? "idle (no assignment yet)"
|
|
62
|
+
: `idle (no assignment yet, ${humanizeAge(prov.age_seconds)} ago)`;
|
|
63
|
+
}
|
|
64
|
+
switch (prov.source) {
|
|
65
|
+
case "not-instrumented":
|
|
66
|
+
return `${prov.state} (not instrumented)`;
|
|
67
|
+
case "tmux-probe":
|
|
68
|
+
return prov.last_seen
|
|
69
|
+
? `${prov.state} (last seen ${humanizeAge(ageSecondsSince(prov.last_seen, now))} ago)`
|
|
70
|
+
: prov.state;
|
|
71
|
+
case "no-record":
|
|
72
|
+
return prov.age_seconds == null
|
|
73
|
+
? `${prov.state} (no record)`
|
|
74
|
+
: `${prov.state} (no record, ${humanizeAge(prov.age_seconds)} ago)`;
|
|
75
|
+
case "hook":
|
|
76
|
+
return `${prov.state} (${prov.event}, ${humanizeAge(prov.age_seconds)} ago)`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function lastLogEvent(actorId, now = Date.now()) {
|
|
80
|
+
const row = stmt("SELECT event, state, created_at FROM agent_state_log WHERE actor_id = ? ORDER BY id DESC LIMIT 1").get(actorId);
|
|
81
|
+
if (!row)
|
|
82
|
+
return null;
|
|
83
|
+
return { event: row.event, state: row.state, age_seconds: ageSecondsSince(row.created_at, now), at: row.created_at };
|
|
84
|
+
}
|
|
85
|
+
export function describeLastLogEvent(log) {
|
|
86
|
+
return log ? `${sanitizeEventForDisplay(log.event)} (${humanizeAge(log.age_seconds)} ago)` : "no record";
|
|
87
|
+
}
|
|
88
|
+
export function reportsAgentStateLog(row) {
|
|
89
|
+
return row.kind === "agent" && harnessFor(row.command).stateSource;
|
|
90
|
+
}
|
|
91
|
+
const PERMISSION_MODE_RE = /"permission_mode":"([a-zA-Z]+)"/;
|
|
92
|
+
// Regex over raw payload, not the last row's JSON.parse: a Notification row never carries this key.
|
|
93
|
+
export function lastPermissionMode(actorId) {
|
|
94
|
+
const row = stmt("SELECT payload FROM agent_state_log WHERE actor_id = ? AND payload LIKE '%\"permission_mode\":%' ORDER BY id DESC LIMIT 1").get(actorId);
|
|
95
|
+
const match = row ? PERMISSION_MODE_RE.exec(row.payload) : null;
|
|
96
|
+
return match ? match[1] : null;
|
|
97
|
+
}
|