@cmgmyr/hive 1.10.0 → 1.11.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/claude-plugin/crew/model.mjs +3 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +125 -46
- package/dist/context.js +7 -1
- package/dist/crew.js +20 -2
- package/dist/db.js +139 -0
- package/dist/help.js +27 -0
- package/dist/hook.js +32 -2
- package/dist/index.js +2 -0
- package/dist/kickoff.js +29 -6
- package/dist/leadHandoff.js +525 -0
- package/dist/leadHandoffCli.js +295 -0
- package/dist/leadHandoffState.js +96 -0
- package/dist/leadLaunch.js +54 -0
- package/dist/leadMessageDelivery.js +44 -7
- package/dist/leadState.js +98 -17
- package/dist/portfolio.js +2 -0
- package/dist/projectRemove.js +1 -0
- package/dist/projectYml.js +8 -1
- package/dist/questions.js +254 -0
- package/dist/scheduler.js +25 -5
- package/dist/spawn.js +44 -15
- package/dist/staleServers.js +122 -0
- package/dist/tmux.js +9 -6
- package/dist/tools/agents.js +35 -35
- package/dist/tools/meta.js +4 -1
- package/dist/tools/questions.js +87 -0
- package/dist/tools/wakes.js +9 -9
- package/package.json +1 -1
package/dist/leadState.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { liveBackgroundTasks } from "./backgroundTasks.js";
|
|
1
2
|
import { db } from "./db.js";
|
|
2
3
|
import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
|
|
4
|
+
import { postponeGrace, readActiveHandoff } from "./leadHandoffState.js";
|
|
3
5
|
export function readLeadTurnState(agentId) {
|
|
4
6
|
return db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null;
|
|
5
7
|
}
|
|
@@ -9,7 +11,23 @@ export function leadSessionEnded(row, ownership) {
|
|
|
9
11
|
const turn = readLeadTurnState(row.id);
|
|
10
12
|
return turn?.last_event === "session_end" && turn.pane_pid === row.pane_pid ? { ended_at: turn.changed_at } : null;
|
|
11
13
|
}
|
|
12
|
-
|
|
14
|
+
// The hive-authored prompt shapes conversationHoldsWake excludes, as SQL over an agent_state_log
|
|
15
|
+
// payload column. isHumanPrompt below is its JS twin; a parity test keeps them in step.
|
|
16
|
+
export function humanPromptSql(col) {
|
|
17
|
+
const p = `CASE WHEN json_valid(${col}) THEN COALESCE(json_extract(${col}, '$.prompt'), ${col}) ELSE ${col} END`;
|
|
18
|
+
return (`${p} NOT LIKE '%[hive wake #%' AND ${p} NOT LIKE '[hive:%' AND ${p} NOT LIKE '<task-notification>%' ` +
|
|
19
|
+
`AND ${p} NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'`);
|
|
20
|
+
}
|
|
21
|
+
// Includes SQLite LIKE's ASCII case-insensitivity.
|
|
22
|
+
export function isHumanPrompt(prompt) {
|
|
23
|
+
const p = prompt.toLowerCase();
|
|
24
|
+
if (p.includes("[hive wake #") || p.startsWith("[hive:") || p.startsWith("<task-notification>"))
|
|
25
|
+
return false;
|
|
26
|
+
return !/^<cross-session-message from="[\s\S]*" from-name="hive">\n\[hive:worker [\s\S]*\] \[message #[\s\S]*,[\s\S]*\n<\/cross-session-message>/.test(p);
|
|
27
|
+
}
|
|
28
|
+
// The agent_state_log state label on hive's own first message to a lead; conversationHoldsWake skips it.
|
|
29
|
+
export const FIRST_MESSAGE_LOG_STATE = "first_message";
|
|
30
|
+
export function isHiveFirstMessage(payload) {
|
|
13
31
|
if (typeof payload.prompt !== "string")
|
|
14
32
|
return false;
|
|
15
33
|
const prompt = payload.prompt.trim();
|
|
@@ -41,24 +59,86 @@ export function nextLeadTurn(cur, panePid, event, payload, subagentsLive) {
|
|
|
41
59
|
return null;
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
export function readLeadSafetySnapshot(agentId) {
|
|
63
|
+
return (db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null);
|
|
64
|
+
}
|
|
65
|
+
function applySnapshot(agentId, panePid, event, payload) {
|
|
66
|
+
const row = readLeadSafetySnapshot(agentId);
|
|
67
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
68
|
+
if (row === null || sessionId === "" || row.pane_pid !== panePid || row.session_id !== sessionId)
|
|
48
69
|
return;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
const fresh = row.snapshot_pane_pid !== panePid || row.snapshot_session_id !== sessionId;
|
|
71
|
+
const snap = {
|
|
72
|
+
prompt_seq: fresh ? 0 : row.prompt_seq,
|
|
73
|
+
human_prompt_seq: fresh ? 0 : row.human_prompt_seq,
|
|
74
|
+
human_prompt_at: fresh ? null : row.human_prompt_at,
|
|
75
|
+
transcript_path: fresh ? "" : row.transcript_path,
|
|
76
|
+
stop_prompt_seq: fresh ? null : row.stop_prompt_seq,
|
|
77
|
+
stop_background: fresh ? null : row.stop_background,
|
|
78
|
+
stop_at: fresh ? null : row.stop_at,
|
|
79
|
+
};
|
|
80
|
+
if (typeof payload.transcript_path === "string" && payload.transcript_path !== "") {
|
|
81
|
+
snap.transcript_path = payload.transcript_path;
|
|
82
|
+
}
|
|
83
|
+
const now = db.prepare("SELECT strftime('%Y-%m-%d %H:%M:%f', 'now') AS now").get().now;
|
|
84
|
+
switch (event) {
|
|
85
|
+
case "prompt":
|
|
86
|
+
snap.prompt_seq += 1;
|
|
87
|
+
if (typeof payload.prompt === "string" && !isHiveFirstMessage(payload) && isHumanPrompt(payload.prompt.trim())) {
|
|
88
|
+
snap.human_prompt_seq += 1;
|
|
89
|
+
snap.human_prompt_at = now;
|
|
90
|
+
// A human prompt during grace postpones that attempt at once; the grace owner sees it too.
|
|
91
|
+
const active = readActiveHandoff(agentId);
|
|
92
|
+
if (active?.state === "grace" && active.predecessor_session_id === sessionId) {
|
|
93
|
+
postponeGrace(active, { predecessor_session_id: sessionId }, "a human prompt arrived during grace", true);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
case "stop":
|
|
98
|
+
snap.stop_prompt_seq = snap.prompt_seq;
|
|
99
|
+
snap.stop_at = now;
|
|
100
|
+
// No background_tasks in the payload is unknown evidence, never an empty list.
|
|
101
|
+
snap.stop_background = payload.background_tasks === undefined
|
|
102
|
+
? null
|
|
103
|
+
: JSON.stringify(liveBackgroundTasks(payload.background_tasks));
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
db.prepare(`UPDATE lead_turn_state SET snapshot_pane_pid = ?, snapshot_session_id = ?, prompt_seq = ?, human_prompt_seq = ?,
|
|
107
|
+
human_prompt_at = ?, transcript_path = ?, stop_prompt_seq = ?, stop_background = ?, stop_at = ?
|
|
108
|
+
WHERE agent_id = ?`).run(panePid, sessionId, snap.prompt_seq, snap.human_prompt_seq, snap.human_prompt_at, snap.transcript_path, snap.stop_prompt_seq, snap.stop_background, snap.stop_at, agentId);
|
|
109
|
+
}
|
|
110
|
+
// The states after a respawn claim, in which the predecessor's session is no longer the lead.
|
|
111
|
+
export const HANDED_OFF_STATES_SQL = "('respawning', 'started', 'completed', 'ambiguous')";
|
|
112
|
+
// A predecessor-session hook is fenced from the respawn claim until the handoff completes or fails;
|
|
113
|
+
// the pane pid cannot tell a dying predecessor from an in-pane /resume, so the row's state decides.
|
|
114
|
+
export function predecessorFence(agentId, sessionId) {
|
|
115
|
+
if (typeof sessionId !== "string" || sessionId === "")
|
|
116
|
+
return false;
|
|
117
|
+
return (db
|
|
118
|
+
.prepare(`SELECT 1 AS hit FROM lead_handoffs WHERE lead_agent_id = ? AND predecessor_session_id = ?
|
|
119
|
+
AND state IN ('respawning', 'started', 'ambiguous')`)
|
|
120
|
+
.get(agentId, sessionId) !== undefined);
|
|
121
|
+
}
|
|
122
|
+
const applyInTransaction = db.transaction((agentId, panePid, event, payload, subagentsLive, fenced) => {
|
|
123
|
+
if (fenced)
|
|
55
124
|
return;
|
|
125
|
+
const cur = readLeadTurnState(agentId);
|
|
126
|
+
const next = nextLeadTurn(cur, panePid, event, payload, subagentsLive);
|
|
127
|
+
const unchanged = next === null ||
|
|
128
|
+
(cur !== null &&
|
|
129
|
+
cur.pane_pid === next.pane_pid &&
|
|
130
|
+
cur.session_id === next.session_id &&
|
|
131
|
+
cur.state === next.state &&
|
|
132
|
+
cur.idle_seq === next.idle_seq &&
|
|
133
|
+
(event !== "session_end" || cur.last_event === "session_end"));
|
|
134
|
+
if (!unchanged) {
|
|
135
|
+
db.prepare(`INSERT INTO lead_turn_state (agent_id, pane_pid, session_id, state, idle_seq, last_event, changed_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
137
|
+
ON CONFLICT(agent_id) DO UPDATE SET pane_pid = excluded.pane_pid, session_id = excluded.session_id,
|
|
138
|
+
state = excluded.state, idle_seq = excluded.idle_seq, last_event = excluded.last_event,
|
|
139
|
+
changed_at = excluded.changed_at`).run(agentId, next.pane_pid, next.session_id, next.state, next.idle_seq, event);
|
|
56
140
|
}
|
|
57
|
-
|
|
58
|
-
VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
59
|
-
ON CONFLICT(agent_id) DO UPDATE SET pane_pid = excluded.pane_pid, session_id = excluded.session_id,
|
|
60
|
-
state = excluded.state, idle_seq = excluded.idle_seq, last_event = excluded.last_event,
|
|
61
|
-
changed_at = excluded.changed_at`).run(agentId, next.pane_pid, next.session_id, next.state, next.idle_seq, event);
|
|
141
|
+
applySnapshot(agentId, panePid, event, payload);
|
|
62
142
|
});
|
|
63
143
|
export function applyLeadHook(actorId, event, payload, subagentsLive) {
|
|
64
144
|
const lead = db
|
|
@@ -66,5 +146,6 @@ export function applyLeadHook(actorId, event, payload, subagentsLive) {
|
|
|
66
146
|
.get(actorId);
|
|
67
147
|
if (!lead)
|
|
68
148
|
return;
|
|
69
|
-
|
|
149
|
+
const fenced = predecessorFence(lead.id, payload.session_id);
|
|
150
|
+
applyInTransaction.immediate(lead.id, lead.pane_pid, event, payload, subagentsLive, fenced);
|
|
70
151
|
}
|
package/dist/portfolio.js
CHANGED
|
@@ -3,6 +3,7 @@ import { listProjects } from "./context.js";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { parseTags } from "./result.js";
|
|
5
5
|
import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
|
|
6
|
+
import { pendingQuestionCount } from "./questions.js";
|
|
6
7
|
import { leadSessionEnded, readLeadTurnState } from "./leadState.js";
|
|
7
8
|
import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
|
|
8
9
|
export const PORTFOLIO_REASONS = [
|
|
@@ -191,6 +192,7 @@ function projectRow(project, now, snapshot) {
|
|
|
191
192
|
workers,
|
|
192
193
|
todos,
|
|
193
194
|
needs_human: tagged.length,
|
|
195
|
+
questions_pending: pendingQuestionCount(project.id),
|
|
194
196
|
needs_human_items: tagged.slice(0, NEEDS_HUMAN_ITEM_CAP).map((t) => ({
|
|
195
197
|
todo_id: t.id,
|
|
196
198
|
title: t.title,
|
package/dist/projectRemove.js
CHANGED
package/dist/projectYml.js
CHANGED
|
@@ -180,7 +180,14 @@ function applyLayer(root, path, source, config, sources, warnings) {
|
|
|
180
180
|
}
|
|
181
181
|
else if (isMapping(value) && typeof value.warn === "number" && Number.isInteger(value.warn) && value.warn > 0 &&
|
|
182
182
|
typeof value.stop === "number" && Number.isInteger(value.stop) && value.stop > value.warn) {
|
|
183
|
-
|
|
183
|
+
const budget = { warn: value.warn, stop: value.stop };
|
|
184
|
+
if (value.auto_handoff !== undefined) {
|
|
185
|
+
if (typeof value.auto_handoff === "boolean")
|
|
186
|
+
budget.auto_handoff = value.auto_handoff;
|
|
187
|
+
else
|
|
188
|
+
warn(`lead_turn_budget.auto_handoff must be true or false; ignoring "${String(value.auto_handoff)}", so automatic handoff stays off.`);
|
|
189
|
+
}
|
|
190
|
+
config.lead_turn_budget = budget;
|
|
184
191
|
sources.lead_turn_budget = source;
|
|
185
192
|
}
|
|
186
193
|
else {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { HUMAN_ACTOR_PREFIX } from "./context.js";
|
|
3
|
+
import { assertSafeWakeBody, resolveWakeDelivery } from "./tools/wakes.js";
|
|
4
|
+
const QUESTION_STATE_CONFLICT = "QUESTION_STATE_CONFLICT";
|
|
5
|
+
const HEADER_MAX_CHARS = 12;
|
|
6
|
+
const MIN_OPTIONS = 2;
|
|
7
|
+
const MAX_OPTIONS = 4;
|
|
8
|
+
function toRecord(row) {
|
|
9
|
+
const { ref_kind, ref_number, options, ...rest } = row;
|
|
10
|
+
return {
|
|
11
|
+
...rest,
|
|
12
|
+
options: options === null ? null : JSON.parse(options),
|
|
13
|
+
ref: ref_kind === null || ref_number === null ? null : { kind: ref_kind, number: ref_number },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const blank = (s) => typeof s !== "string" || s.trim() === "";
|
|
17
|
+
function validateRef(ref) {
|
|
18
|
+
if (ref === undefined)
|
|
19
|
+
return;
|
|
20
|
+
if (ref.kind !== "todo" && ref.kind !== "pr")
|
|
21
|
+
throw new Error(`ref.kind must be "todo" or "pr", got ${JSON.stringify(ref.kind)}.`);
|
|
22
|
+
if (!Number.isSafeInteger(ref.number) || ref.number <= 0)
|
|
23
|
+
throw new Error("ref.number must be a positive safe integer.");
|
|
24
|
+
}
|
|
25
|
+
function validateInput(input) {
|
|
26
|
+
if (input.kind !== "choice" && input.kind !== "free_text") {
|
|
27
|
+
throw new Error(`kind must be "choice" or "free_text", got ${JSON.stringify(input.kind)}.`);
|
|
28
|
+
}
|
|
29
|
+
if (blank(input.question))
|
|
30
|
+
throw new Error("question cannot be blank.");
|
|
31
|
+
if (blank(input.header))
|
|
32
|
+
throw new Error("header cannot be blank.");
|
|
33
|
+
if ([...input.header].length > HEADER_MAX_CHARS) {
|
|
34
|
+
throw new Error(`header is ${[...input.header].length} characters; the limit is ${HEADER_MAX_CHARS}.`);
|
|
35
|
+
}
|
|
36
|
+
if (blank(input.default_answer))
|
|
37
|
+
throw new Error("default_answer cannot be blank: every question carries its default.");
|
|
38
|
+
validateRef(input.ref);
|
|
39
|
+
const wakeBound = [input.question, input.header, input.default_answer];
|
|
40
|
+
if (input.kind === "free_text") {
|
|
41
|
+
if (input.options !== undefined)
|
|
42
|
+
throw new Error("a free_text question takes no options.");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const options = input.options;
|
|
46
|
+
if (!Array.isArray(options) || options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) {
|
|
47
|
+
throw new Error(`a choice question needs ${MIN_OPTIONS} to ${MAX_OPTIONS} options, got ${Array.isArray(options) ? options.length : 0}.`);
|
|
48
|
+
}
|
|
49
|
+
const labels = new Set();
|
|
50
|
+
for (const o of options) {
|
|
51
|
+
if (blank(o.label))
|
|
52
|
+
throw new Error("an option label cannot be blank.");
|
|
53
|
+
if (blank(o.description))
|
|
54
|
+
throw new Error(`option "${o.label}" needs a description.`);
|
|
55
|
+
if (labels.has(o.label))
|
|
56
|
+
throw new Error(`option label "${o.label}" is duplicated; labels must be distinct.`);
|
|
57
|
+
labels.add(o.label);
|
|
58
|
+
wakeBound.push(o.label, o.description);
|
|
59
|
+
}
|
|
60
|
+
const recommended = options.filter((o) => o.recommended);
|
|
61
|
+
if (recommended.length !== 1) {
|
|
62
|
+
throw new Error(`exactly one option must be recommended, got ${recommended.length}.`);
|
|
63
|
+
}
|
|
64
|
+
if (input.default_answer !== recommended[0].label) {
|
|
65
|
+
throw new Error(`default_answer must be the recommended option's label ("${recommended[0].label}").`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const text of wakeBound)
|
|
69
|
+
assertSafeWakeBody(text);
|
|
70
|
+
}
|
|
71
|
+
const tokensOf = (text) => new Set((text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((t) => [...t].length >= 4));
|
|
72
|
+
const normalized = (text) => text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
73
|
+
export function looksSimilar(a, b) {
|
|
74
|
+
if (normalized(a) === normalized(b))
|
|
75
|
+
return true;
|
|
76
|
+
const ta = tokensOf(a);
|
|
77
|
+
const tb = tokensOf(b);
|
|
78
|
+
let shared = 0;
|
|
79
|
+
for (const t of ta)
|
|
80
|
+
if (tb.has(t))
|
|
81
|
+
shared++;
|
|
82
|
+
const union = ta.size + tb.size - shared;
|
|
83
|
+
return union > 0 && shared / union >= 0.5;
|
|
84
|
+
}
|
|
85
|
+
function similarPendingIds(projectId, selfId, question) {
|
|
86
|
+
const rows = db
|
|
87
|
+
.prepare("SELECT id, question FROM questions WHERE project_id = ? AND state = 'pending' AND id != ? ORDER BY id")
|
|
88
|
+
.all(projectId, selfId);
|
|
89
|
+
return rows.filter((r) => looksSimilar(question, r.question)).map((r) => r.id);
|
|
90
|
+
}
|
|
91
|
+
function runningAgentOf(projectId, actor) {
|
|
92
|
+
const row = db
|
|
93
|
+
.prepare("SELECT id FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
|
|
94
|
+
.get(projectId, actor);
|
|
95
|
+
return row?.id ?? null;
|
|
96
|
+
}
|
|
97
|
+
const optionsJson = (input) => (input.kind === "choice" ? JSON.stringify(input.options) : null);
|
|
98
|
+
function pendingIdForRef(projectId, ref) {
|
|
99
|
+
const row = db
|
|
100
|
+
.prepare("SELECT id FROM questions WHERE project_id = ? AND ref_kind = ? AND ref_number = ? AND state = 'pending'")
|
|
101
|
+
.get(projectId, ref.kind, ref.number);
|
|
102
|
+
return row?.id;
|
|
103
|
+
}
|
|
104
|
+
const upsertQuestion = db.transaction((projectId, actor, input, askerChecked) => {
|
|
105
|
+
if (input.ref) {
|
|
106
|
+
const existing = pendingIdForRef(projectId, input.ref);
|
|
107
|
+
if (existing !== undefined) {
|
|
108
|
+
db.prepare(`UPDATE questions SET kind = ?, question = ?, header = ?, options = ?, default_answer = ?,
|
|
109
|
+
updated_at = datetime('now'), updated_by = ? WHERE id = ?`).run(input.kind, input.question, input.header, optionsJson(input), input.default_answer, actor, existing);
|
|
110
|
+
return { id: existing, created: false };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!askerChecked) {
|
|
114
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: the pending question for this ref changed while it was being asked; ask again.`);
|
|
115
|
+
}
|
|
116
|
+
const row = db
|
|
117
|
+
.prepare(`INSERT INTO questions (project_id, asker, kind, question, header, options, default_answer,
|
|
118
|
+
ref_kind, ref_number, updated_by)
|
|
119
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`)
|
|
120
|
+
.get(projectId, actor, input.kind, input.question, input.header, optionsJson(input), input.default_answer, input.ref?.kind ?? null, input.ref?.number ?? null, actor);
|
|
121
|
+
return { id: row.id, created: true };
|
|
122
|
+
});
|
|
123
|
+
export function askQuestion(projectId, actor, input) {
|
|
124
|
+
validateInput(input);
|
|
125
|
+
const refTaken = input.ref !== undefined && pendingIdForRef(projectId, input.ref) !== undefined;
|
|
126
|
+
let askerChecked = false;
|
|
127
|
+
if (!refTaken) {
|
|
128
|
+
resolveWakeDelivery(projectId);
|
|
129
|
+
askerChecked = runningAgentOf(projectId, actor) !== null;
|
|
130
|
+
if (!askerChecked) {
|
|
131
|
+
throw new Error("This session has no running hive agent row, so an answer could never be woken back to it; a question needs an asker that can be woken.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const { id, created } = upsertQuestion.immediate(projectId, actor, input, askerChecked);
|
|
135
|
+
return { question_id: id, created, similar_question_ids: similarPendingIds(projectId, id, input.question) };
|
|
136
|
+
}
|
|
137
|
+
function fetchRow(projectId, id) {
|
|
138
|
+
const row = db.prepare("SELECT * FROM questions WHERE project_id = ? AND id = ?").get(projectId, id);
|
|
139
|
+
if (!row)
|
|
140
|
+
throw new Error(`No question ${id} in this project. Call question_list.`);
|
|
141
|
+
return row;
|
|
142
|
+
}
|
|
143
|
+
export function getQuestion(projectId, id) {
|
|
144
|
+
return toRecord(fetchRow(projectId, id));
|
|
145
|
+
}
|
|
146
|
+
export function listQuestions(projectId) {
|
|
147
|
+
const rows = db
|
|
148
|
+
.prepare("SELECT * FROM questions WHERE project_id = ? AND state IN ('pending', 'defaulted') ORDER BY (state = 'defaulted'), id")
|
|
149
|
+
.all(projectId);
|
|
150
|
+
return rows.map(toRecord);
|
|
151
|
+
}
|
|
152
|
+
export function pendingQuestionCount(projectId) {
|
|
153
|
+
return db.prepare("SELECT COUNT(*) AS n FROM questions WHERE project_id = ? AND state = 'pending'").get(projectId).n;
|
|
154
|
+
}
|
|
155
|
+
const TEXT_PATCH_KEYS = ["question", "header", "options", "default_answer"];
|
|
156
|
+
const patchQuestion = db.transaction((projectId, actor, id, patch) => {
|
|
157
|
+
const current = toRecord(fetchRow(projectId, id));
|
|
158
|
+
const editsText = TEXT_PATCH_KEYS.some((k) => patch[k] !== undefined);
|
|
159
|
+
if (editsText && current.state !== "pending") {
|
|
160
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is ${current.state}; only a pending question's text can be edited.`);
|
|
161
|
+
}
|
|
162
|
+
if (patch.state !== undefined) {
|
|
163
|
+
if (patch.state !== "defaulted" && patch.state !== "withdrawn") {
|
|
164
|
+
throw new Error(`state must be "defaulted" or "withdrawn", got ${JSON.stringify(patch.state)}.`);
|
|
165
|
+
}
|
|
166
|
+
const allowed = patch.state === "withdrawn" ? ["pending", "defaulted"] : ["pending"];
|
|
167
|
+
if (!allowed.includes(current.state)) {
|
|
168
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is ${current.state} and cannot become ${patch.state}.`);
|
|
169
|
+
}
|
|
170
|
+
if (actor !== current.asker && !actor.startsWith(HUMAN_ACTOR_PREFIX)) {
|
|
171
|
+
throw new Error(`Only the asking session (${current.asker}) or the human can mark question ${id} ${patch.state}.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let merged = null;
|
|
175
|
+
if (editsText) {
|
|
176
|
+
if (current.kind === "free_text" && patch.options !== undefined)
|
|
177
|
+
throw new Error("a free_text question takes no options.");
|
|
178
|
+
const base = {
|
|
179
|
+
question: patch.question ?? current.question,
|
|
180
|
+
header: patch.header ?? current.header,
|
|
181
|
+
default_answer: patch.default_answer ?? current.default_answer,
|
|
182
|
+
...(current.ref ? { ref: current.ref } : {}),
|
|
183
|
+
};
|
|
184
|
+
merged =
|
|
185
|
+
current.kind === "choice"
|
|
186
|
+
? { kind: "choice", ...base, options: patch.options ?? current.options }
|
|
187
|
+
: { kind: "free_text", ...base };
|
|
188
|
+
validateInput(merged);
|
|
189
|
+
}
|
|
190
|
+
const sets = ["updated_at = datetime('now')", "updated_by = ?"];
|
|
191
|
+
const params = [actor];
|
|
192
|
+
if (merged) {
|
|
193
|
+
sets.push("question = ?", "header = ?", "options = ?", "default_answer = ?");
|
|
194
|
+
params.push(merged.question, merged.header, optionsJson(merged), merged.default_answer);
|
|
195
|
+
}
|
|
196
|
+
if (patch.state !== undefined) {
|
|
197
|
+
sets.push("state = ?");
|
|
198
|
+
params.push(patch.state);
|
|
199
|
+
}
|
|
200
|
+
db.prepare(`UPDATE questions SET ${sets.join(", ")} WHERE id = ?`).run(...params, id);
|
|
201
|
+
return { question_id: id, state: patch.state ?? current.state };
|
|
202
|
+
});
|
|
203
|
+
export function updateQuestion(projectId, actor, id, patch) {
|
|
204
|
+
if (Object.keys(patch).filter((k) => patch[k] !== undefined).length === 0) {
|
|
205
|
+
throw new Error("an empty patch changes nothing; pass at least one field to change.");
|
|
206
|
+
}
|
|
207
|
+
return patchQuestion.immediate(projectId, actor, id, patch);
|
|
208
|
+
}
|
|
209
|
+
export function answerWakeBody(id, question, answer, notes) {
|
|
210
|
+
const head = `Question #${id} answered.\nQuestion: ${question}\nAnswer: ${answer}`;
|
|
211
|
+
return notes !== null && notes.trim() !== "" ? `${head}\nNotes: ${notes}` : head;
|
|
212
|
+
}
|
|
213
|
+
function planAnswerWake(projectId, row, actor) {
|
|
214
|
+
if (actor === row.asker)
|
|
215
|
+
return { skipped: "answered by the asker" };
|
|
216
|
+
const agentId = runningAgentOf(projectId, row.asker);
|
|
217
|
+
if (agentId === null)
|
|
218
|
+
return { skipped: `the asker ${row.asker} has no running agent` };
|
|
219
|
+
try {
|
|
220
|
+
return { delivery: resolveWakeDelivery(projectId, agentId) };
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
return { skipped: e instanceof Error ? e.message : String(e) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const commitAnswer = db.transaction((projectId, actor, id, answer, notes, plan) => {
|
|
227
|
+
const row = fetchRow(projectId, id);
|
|
228
|
+
if (row.state !== "pending" && row.state !== "defaulted") {
|
|
229
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is already ${row.state}.`);
|
|
230
|
+
}
|
|
231
|
+
db.prepare(`UPDATE questions SET state = 'answered', answer = ?, notes = ?, answered_by = ?, updated_by = ?,
|
|
232
|
+
updated_at = datetime('now') WHERE id = ?`).run(answer, notes, actor, actor, id);
|
|
233
|
+
if ("skipped" in plan)
|
|
234
|
+
return { question_id: id, wake_id: null, wake_skipped: plan.skipped };
|
|
235
|
+
const wake = db
|
|
236
|
+
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at)
|
|
237
|
+
VALUES (?, ?, ?, 'delay', ?, ?, datetime('now')) RETURNING id`)
|
|
238
|
+
.get(projectId, actor, answerWakeBody(id, row.question, answer, notes), plan.delivery.actor, plan.delivery.pane);
|
|
239
|
+
return { question_id: id, wake_id: wake.id };
|
|
240
|
+
});
|
|
241
|
+
export function answerQuestion(projectId, actor, id, answer, notes) {
|
|
242
|
+
if (blank(answer))
|
|
243
|
+
throw new Error("answer cannot be blank.");
|
|
244
|
+
const storedNotes = notes === undefined || notes.trim() === "" ? null : notes;
|
|
245
|
+
assertSafeWakeBody(answer);
|
|
246
|
+
if (storedNotes !== null)
|
|
247
|
+
assertSafeWakeBody(storedNotes);
|
|
248
|
+
const row = fetchRow(projectId, id);
|
|
249
|
+
if (row.state !== "pending" && row.state !== "defaulted") {
|
|
250
|
+
throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is already ${row.state}.`);
|
|
251
|
+
}
|
|
252
|
+
const plan = planAnswerWake(projectId, row, actor);
|
|
253
|
+
return commitAnswer.immediate(projectId, actor, id, answer, storedNotes, plan);
|
|
254
|
+
}
|
package/dist/scheduler.js
CHANGED
|
@@ -18,7 +18,9 @@ import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
|
|
|
18
18
|
import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
|
|
19
19
|
import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
|
|
20
20
|
import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress, SOCKET_WAKE_FOOTER } from "./claudeWake.js";
|
|
21
|
-
import {
|
|
21
|
+
import { driveLeadHandoff } from "./leadHandoff.js";
|
|
22
|
+
import { handoffHoldsWake, HELD_REASON_HANDOFF } from "./leadHandoffState.js";
|
|
23
|
+
import { FIRST_MESSAGE_LOG_STATE, humanPromptSql, readLeadTurnState } from "./leadState.js";
|
|
22
24
|
import { retryQuietLeadMessages } from "./leadMessageDelivery.js";
|
|
23
25
|
import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
|
|
24
26
|
import { transcriptDir, readContextFill } from "./transcript.js";
|
|
@@ -481,6 +483,17 @@ export function checkConfirmations() {
|
|
|
481
483
|
catch {
|
|
482
484
|
}
|
|
483
485
|
}
|
|
486
|
+
// Only the lead's own server drives its handoff; every server honours the hold it produces.
|
|
487
|
+
function driveOwnLeadHandoff() {
|
|
488
|
+
const actor = process.env.HIVE_AGENT_ID;
|
|
489
|
+
if (!actor || !isLeadActorId(actor))
|
|
490
|
+
return;
|
|
491
|
+
try {
|
|
492
|
+
driveLeadHandoff(actor, (path) => loadProjectYml(path).config?.lead_turn_budget ?? null);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
}
|
|
496
|
+
}
|
|
484
497
|
export async function tick(snapshot) {
|
|
485
498
|
if (ticking)
|
|
486
499
|
return;
|
|
@@ -496,6 +509,7 @@ export async function tick(snapshot) {
|
|
|
496
509
|
janitor(snapshot);
|
|
497
510
|
if (snapshot)
|
|
498
511
|
reportRunningBuildChange(snapshot);
|
|
512
|
+
driveOwnLeadHandoff();
|
|
499
513
|
checkConfirmations();
|
|
500
514
|
pruneStateLog();
|
|
501
515
|
maybeBackupHourly(db, dataDir);
|
|
@@ -667,11 +681,9 @@ function conversationHoldsWake(timer, snapshot) {
|
|
|
667
681
|
return false;
|
|
668
682
|
}
|
|
669
683
|
return (stmt(`SELECT 1 AS hit FROM agent_state_log
|
|
670
|
-
WHERE actor_id = ? AND event = 'prompt' AND
|
|
671
|
-
AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<task-notification>%'
|
|
672
|
-
AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'
|
|
684
|
+
WHERE actor_id = ? AND event = 'prompt' AND state != ? AND ${humanPromptSql("payload")}
|
|
673
685
|
AND created_at >= datetime('now', ?)
|
|
674
|
-
ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, CONVERSATION_HOLD_TTL) !== undefined);
|
|
686
|
+
ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, FIRST_MESSAGE_LOG_STATE, CONVERSATION_HOLD_TTL) !== undefined);
|
|
675
687
|
}
|
|
676
688
|
function heldTarget(timer) {
|
|
677
689
|
const row = stmt(`SELECT id, name, kind, COALESCE(state_changed_at, '') AS blocked_since FROM agents WHERE actor_id = ?
|
|
@@ -1530,6 +1542,10 @@ function deliverable(timer, snapshot, choices) {
|
|
|
1530
1542
|
else {
|
|
1531
1543
|
owner = { rowId: row.deliver_row_id, identity: deliveryIdentity(row) };
|
|
1532
1544
|
}
|
|
1545
|
+
if (handoffHoldsWake(timer.id, row.deliver_row_id)) {
|
|
1546
|
+
holdTimer(timer, HELD_REASON_HANDOFF);
|
|
1547
|
+
return { ok: false };
|
|
1548
|
+
}
|
|
1533
1549
|
// Above both pane reads: this population must not be captured at all, and the verdicts would be
|
|
1534
1550
|
// wrong in opposite directions if they were.
|
|
1535
1551
|
if (!screenClassifiable(timer.deliver_command)) {
|
|
@@ -1609,6 +1625,10 @@ async function withPaneClaim(timer, fn) {
|
|
|
1609
1625
|
}
|
|
1610
1626
|
}
|
|
1611
1627
|
async function fireDelay(timer, snapshot, choices) {
|
|
1628
|
+
if (handoffHoldsWake(timer.id, timer.deliver_row_id)) {
|
|
1629
|
+
holdTimer(timer, HELD_REASON_HANDOFF);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1612
1632
|
const disposition = noticeDisposition(timer);
|
|
1613
1633
|
if (disposition === "aged") {
|
|
1614
1634
|
try {
|
package/dist/spawn.js
CHANGED
|
@@ -4,7 +4,7 @@ import { preservedRolloutPath, reapCodexHome } from "./codexHome.js";
|
|
|
4
4
|
import { getProject } from "./context.js";
|
|
5
5
|
import { dataDir, db } from "./db.js";
|
|
6
6
|
import { loadProjectYml } from "./projectYml.js";
|
|
7
|
-
import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget,
|
|
7
|
+
import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, paneProcessExited, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, setRemainOnExit, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, rowOwnership, } from "./tmux.js";
|
|
8
8
|
export function withWindowClaim(claim) {
|
|
9
9
|
if (db.inTransaction) {
|
|
10
10
|
throw new Error("withWindowClaim was called from inside an already-open transaction. better-sqlite3 nests a " +
|
|
@@ -56,13 +56,21 @@ function recordPane(agentId, target, socket, pid) {
|
|
|
56
56
|
.prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ? WHERE id = ? AND status = 'running'")
|
|
57
57
|
.run(target, socket, pid, agentId).changes > 0);
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// The creation pid proves the pane existed, not that its process still runs. A retained pane is
|
|
60
|
+
// left to the caller's readiness wait, which reports the exit with its tail.
|
|
61
|
+
function refuseIfExitedImmediately(target, retained) {
|
|
62
|
+
if (retained || paneProcessExited(target) !== true)
|
|
63
|
+
return;
|
|
64
|
+
discardOrphanedPane(target);
|
|
65
|
+
throw new Error(`The command exited immediately, so its pane ${target} was discarded rather than recorded as running. ` +
|
|
66
|
+
"Check the command and its arguments, then retry.");
|
|
67
|
+
}
|
|
68
|
+
export function createdPidOrDiscard(target, pid) {
|
|
69
|
+
if (/^\d+$/.test(pid))
|
|
62
70
|
return pid;
|
|
63
71
|
discardOrphanedPane(target);
|
|
64
|
-
throw new Error(`tmux did not report a process id for the new pane ${target}
|
|
65
|
-
"recorded: a row with no pane pid can never be verified as owning its pane. Retry.");
|
|
72
|
+
throw new Error(`tmux did not report a process id for the new pane ${target} when it created it, so it was discarded rather ` +
|
|
73
|
+
"than recorded: a row with no pane pid can never be verified as owning its pane. Retry.");
|
|
66
74
|
}
|
|
67
75
|
// Two callers, one meaning: whatever is left in the window re-tiles to the layout it was arranged
|
|
68
76
|
// with. agent_close kills a worker's pane through this; stopProcess (src/processes.ts) is the only
|
|
@@ -116,10 +124,11 @@ export function upsertActor(actorId, name, kind) {
|
|
|
116
124
|
db.prepare(`INSERT INTO actors (id, name, kind) VALUES (?, ?, ?)
|
|
117
125
|
ON CONFLICT(id) DO UPDATE SET name = excluded.name, last_seen_at = datetime('now')`).run(actorId, name, kind);
|
|
118
126
|
}
|
|
119
|
-
function placeAgentPane(session, spec, envFlags, commandString, title) {
|
|
127
|
+
function placeAgentPane(session, spec, envFlags, commandString, title, onRetainArmed = () => { }) {
|
|
120
128
|
let landedInProjectId = null;
|
|
121
129
|
let layoutApplied = false;
|
|
122
130
|
let inProcessesWindow = false;
|
|
131
|
+
let pid = "";
|
|
123
132
|
const target = withWindowClaim(() => {
|
|
124
133
|
const windowName = spec.placement === "split" ? spec.projectName : title;
|
|
125
134
|
const windowOwnerId = spec.placement === "split" ? spec.projectId : null;
|
|
@@ -127,10 +136,14 @@ function placeAgentPane(session, spec, envFlags, commandString, title) {
|
|
|
127
136
|
if (started.created) {
|
|
128
137
|
// Deliberate for placement "processes" too: this pane IS the new session's initial window, so it
|
|
129
138
|
// takes that window rather than a tile. The caller reports it; `hive hide` moves it in later.
|
|
139
|
+
pid = started.pid;
|
|
130
140
|
return claimInitialWindow(started, windowName, windowOwnerId).pane;
|
|
131
141
|
}
|
|
132
142
|
const splitInto = (window, layout) => {
|
|
133
|
-
const pane = tmux("split-window", "-d", "-P", "-F", "#{pane_id}", "-t", window, "-c", spec.cwd, ...envFlags, commandString, ...(spec.retainOnExit ? retainOnExitArgs(window) : []));
|
|
143
|
+
const [pane, created = ""] = tmux("split-window", "-d", "-P", "-F", "#{pane_id}\t#{pane_pid}", "-t", window, "-c", spec.cwd, ...envFlags, commandString, ...(spec.retainOnExit ? retainOnExitArgs(window) : [])).split("\t");
|
|
144
|
+
pid = created;
|
|
145
|
+
if (spec.retainOnExit)
|
|
146
|
+
onRetainArmed(window);
|
|
134
147
|
applyLayout(window, layout);
|
|
135
148
|
layoutApplied = true;
|
|
136
149
|
return pane;
|
|
@@ -143,6 +156,9 @@ function placeAgentPane(session, spec, envFlags, commandString, title) {
|
|
|
143
156
|
}
|
|
144
157
|
else {
|
|
145
158
|
const made = createWindow(session, processesWindowName(spec.projectName), spec.cwd, envFlags, commandString, null, true, spec.retainOnExit ?? false);
|
|
159
|
+
pid = made.pid;
|
|
160
|
+
if (spec.retainOnExit)
|
|
161
|
+
onRetainArmed(made.window);
|
|
146
162
|
makeProcessesWindow(made.window, spec.projectId);
|
|
147
163
|
pane = made.pane;
|
|
148
164
|
}
|
|
@@ -160,9 +176,13 @@ function placeAgentPane(session, spec, envFlags, commandString, title) {
|
|
|
160
176
|
return pane;
|
|
161
177
|
}
|
|
162
178
|
}
|
|
163
|
-
|
|
179
|
+
const made = createWindow(session, windowName, spec.cwd, envFlags, commandString, windowOwnerId, true, spec.retainOnExit ?? false);
|
|
180
|
+
pid = made.pid;
|
|
181
|
+
if (spec.retainOnExit)
|
|
182
|
+
onRetainArmed(made.window);
|
|
183
|
+
return made.pane;
|
|
164
184
|
});
|
|
165
|
-
return { target, landedInProjectId, layoutApplied, inProcessesWindow };
|
|
185
|
+
return { target, pid, landedInProjectId, layoutApplied, inProcessesWindow };
|
|
166
186
|
}
|
|
167
187
|
export function launchAgent(spec) {
|
|
168
188
|
if (untrustedTmuxServer())
|
|
@@ -183,6 +203,7 @@ export function launchAgent(spec) {
|
|
|
183
203
|
const agentId = Number(info.lastInsertRowid);
|
|
184
204
|
const actorId = `${spec.kind}:${agentId}`;
|
|
185
205
|
let paneUp = false;
|
|
206
|
+
let retainedWindow = null;
|
|
186
207
|
try {
|
|
187
208
|
const commandString = typeof spec.commandString === "string" ? spec.commandString : spec.commandString({ agentId, actorId });
|
|
188
209
|
db.prepare("UPDATE agents SET actor_id = ?, command = ? WHERE id = ?").run(actorId, commandString, agentId);
|
|
@@ -193,16 +214,22 @@ export function launchAgent(spec) {
|
|
|
193
214
|
: spec.env;
|
|
194
215
|
const envFlags = buildEnvFlags(env);
|
|
195
216
|
const title = windowTitle(spec.projectName, spec.name);
|
|
196
|
-
const
|
|
197
|
-
|
|
217
|
+
const placed = placeAgentPane(session, spec, envFlags, commandString, title, (window) => {
|
|
218
|
+
retainedWindow = window;
|
|
219
|
+
});
|
|
220
|
+
const { target, landedInProjectId, layoutApplied, inProcessesWindow } = placed;
|
|
221
|
+
const pid = createdPidOrDiscard(target, placed.pid);
|
|
222
|
+
refuseIfExitedImmediately(target, spec.retainOnExit ?? false);
|
|
198
223
|
paneUp = true;
|
|
199
224
|
if (!recordPane(agentId, target, socket, pid)) {
|
|
200
225
|
discardOrphanedPane(target);
|
|
201
226
|
throw paneRacedRetirement(agentId);
|
|
202
227
|
}
|
|
203
|
-
return { agentId, actorId, target, landedInProjectId, layoutApplied, inProcessesWindow };
|
|
228
|
+
return { agentId, actorId, target, landedInProjectId, layoutApplied, inProcessesWindow, retainedWindow };
|
|
204
229
|
}
|
|
205
230
|
catch (e) {
|
|
231
|
+
if (retainedWindow)
|
|
232
|
+
setRemainOnExit(retainedWindow, false);
|
|
206
233
|
if (paneUp)
|
|
207
234
|
throw e;
|
|
208
235
|
db.prepare("DELETE FROM agents WHERE id = ?").run(agentId);
|
|
@@ -269,8 +296,10 @@ export function resumeAgent(spec) {
|
|
|
269
296
|
});
|
|
270
297
|
const session = sessionName();
|
|
271
298
|
const title = windowTitle(spec.projectName, spec.name);
|
|
272
|
-
const
|
|
273
|
-
const
|
|
299
|
+
const placed = placeAgentPane(session, spec, envFlags, spec.commandString, title);
|
|
300
|
+
const { target, landedInProjectId } = placed;
|
|
301
|
+
const pid = createdPidOrDiscard(target, placed.pid);
|
|
302
|
+
refuseIfExitedImmediately(target, false);
|
|
274
303
|
paneUp = true;
|
|
275
304
|
if (!recordPane(spec.agentId, target, socket, pid)) {
|
|
276
305
|
discardOrphanedPane(target);
|