@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/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
- function isHiveFirstMessage(payload) {
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
- const applyInTransaction = db.transaction((agentId, panePid, event, payload, subagentsLive) => {
45
- const cur = readLeadTurnState(agentId);
46
- const next = nextLeadTurn(cur, panePid, event, payload, subagentsLive);
47
- if (next === null)
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
- if (cur !== null &&
50
- cur.pane_pid === next.pane_pid &&
51
- cur.session_id === next.session_id &&
52
- cur.state === next.state &&
53
- cur.idle_seq === next.idle_seq &&
54
- (event !== "session_end" || cur.last_event === "session_end")) {
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
- db.prepare(`INSERT INTO lead_turn_state (agent_id, pane_pid, session_id, state, idle_seq, last_event, changed_at)
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
- applyInTransaction.immediate(lead.id, lead.pane_pid, event, payload, subagentsLive);
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,
@@ -10,6 +10,7 @@ export const PROJECT_OWNER_TABLES = [
10
10
  "agents",
11
11
  "wakes",
12
12
  "command_trust",
13
+ "questions",
13
14
  ];
14
15
  const COUNTED_TABLES = [
15
16
  ...PROJECT_OWNER_TABLES.map((t) => [t, `SELECT COUNT(*) AS n FROM ${t} WHERE project_id = ?`]),
@@ -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
- config.lead_turn_budget = { warn: value.warn, stop: value.stop };
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 { readLeadTurnState } from "./leadState.js";
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 CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '%[hive wake #%' AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '[hive:%'
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, panePidForRecord, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, rowOwnership, } from "./tmux.js";
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
- function readPidOrDiscard(target) {
60
- const pid = panePidForRecord(target);
61
- if (pid !== "")
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} (asked twice), so it was discarded rather than ` +
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
- return createWindow(session, windowName, spec.cwd, envFlags, commandString, windowOwnerId, true, spec.retainOnExit ?? false).pane;
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 { target, landedInProjectId, layoutApplied, inProcessesWindow } = placeAgentPane(session, spec, envFlags, commandString, title);
197
- const pid = readPidOrDiscard(target);
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 { target, landedInProjectId } = placeAgentPane(session, spec, envFlags, spec.commandString, title);
273
- const pid = readPidOrDiscard(target);
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);