@cmgmyr/hive 1.9.0 → 1.10.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.
@@ -0,0 +1,379 @@
1
+ import { join } from "node:path";
2
+ import { dataDir, db } from "./db.js";
3
+ import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress } from "./claudeWake.js";
4
+ import { harnessFor, paneClassifierFor } from "./harnesses.js";
5
+ import { LEAD_MESSAGE_THRESHOLD, renderLeadMessage, senderTag, shortenedSendNote, storeLeadMessage, } from "./leadMessage.js";
6
+ import { readLeadTurnState } from "./leadState.js";
7
+ import { loadProjectYml } from "./projectYml.js";
8
+ import { currentOwnership, PaneOwnershipLost, paneIdentity, requireStillOwned } from "./spawn.js";
9
+ import { holdsHumanInput, paneInCopyMode, sendText, sleep, TmuxTimeoutError, } from "./tmux.js";
10
+ export const SOCKET_MESSAGE_FOOTER = "Automated hive worker message. Do not reply to this socket sender.";
11
+ export const MESSAGE_STATUS = {
12
+ socketPending: "socket-pending",
13
+ fallbackPending: "fallback-pending",
14
+ fallbackClaimed: "fallback-claimed",
15
+ complete: "complete",
16
+ failed: "failed",
17
+ };
18
+ export const MESSAGE_METHOD = {
19
+ socket: "socket",
20
+ pty: "pty",
21
+ ptyAfterSocketTimeout: "pty-after-socket-timeout",
22
+ };
23
+ const PANE_LEASE_TTL_SECONDS = 6;
24
+ const PANE_LEASE_SETTLE_MS = 500;
25
+ const CONFIRM_GRACE_SECONDS = 60;
26
+ const ENVELOPE_OPEN = '<cross-session-message from="';
27
+ const ENVELOPE_SENDER_CLOSE = '" from-name="hive">';
28
+ const EV_PROMPT = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
29
+ const EV_MARKER = "(agent_messages.sender_tag || '[message #' || agent_messages.id || ',')";
30
+ const EV_HEADER_END = `instr(${EV_PROMPT}, char(10))`;
31
+ // The exact "[hive:worker NAME] [message #N," at prompt offset 0 (typed) or opening hive's own envelope body (socket).
32
+ const messageEvidenceSql = (select) => `
33
+ SELECT ${select} FROM agent_state_log ev
34
+ WHERE ev.actor_id = (SELECT a.actor_id FROM agents a WHERE a.id = agent_messages.to_agent_id)
35
+ AND ev.event = 'prompt'
36
+ AND ev.created_at >= COALESCE(agent_messages.socket_attempt_at, agent_messages.created_at)
37
+ AND (substr(${EV_PROMPT}, 1, length(${EV_MARKER})) = ${EV_MARKER}
38
+ OR (${EV_HEADER_END} > ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length}
39
+ AND substr(${EV_PROMPT}, 1, ${ENVELOPE_OPEN.length}) = '${ENVELOPE_OPEN}'
40
+ AND substr(${EV_PROMPT}, ${EV_HEADER_END} - ${ENVELOPE_SENDER_CLOSE.length}, ${ENVELOPE_SENDER_CLOSE.length}) = '${ENVELOPE_SENDER_CLOSE}'
41
+ AND instr(substr(${EV_PROMPT}, ${ENVELOPE_OPEN.length + 1}, ${EV_HEADER_END} - ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length + 1}), '"') = 0
42
+ AND substr(${EV_PROMPT}, ${EV_HEADER_END} + 1, length(${EV_MARKER})) = ${EV_MARKER}
43
+ AND instr(${EV_PROMPT}, char(10) || '${CROSS_SESSION_CLOSE}') > ${EV_HEADER_END}))`;
44
+ const RETAINED = "agent_messages.created_at >= datetime('now', '-7 days')";
45
+ function quietMessagingOn(projectId) {
46
+ const project = db.prepare("SELECT path FROM projects WHERE id = ?").get(projectId);
47
+ return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
48
+ }
49
+ function eligible(options) {
50
+ const { projectId, fromActor, target, submit } = options;
51
+ if (!submit || target.kind !== "lead" || target.status !== "running" || target.actor_id === fromActor)
52
+ return false;
53
+ if (harnessFor(target.command).name !== "claude")
54
+ return false;
55
+ const sender = db
56
+ .prepare("SELECT 1 AS hit FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running' AND kind = 'agent'")
57
+ .get(projectId, fromActor);
58
+ return sender !== undefined && quietMessagingOn(projectId);
59
+ }
60
+ function registeredSocket(target) {
61
+ return (target.claude_messaging_socket !== "" &&
62
+ target.claude_messaging_pane_pid !== "" &&
63
+ target.claude_messaging_pane_pid === target.pane_pid);
64
+ }
65
+ function leadSession(agentId, panePid) {
66
+ const turn = readLeadTurnState(agentId);
67
+ return turn !== null && turn.pane_pid === panePid ? turn.session_id : "";
68
+ }
69
+ function readRow(id) {
70
+ return db.prepare("SELECT * FROM agent_messages WHERE id = ?").get(id);
71
+ }
72
+ function setStatus(id, from, to, fields = {}) {
73
+ const names = Object.keys(fields);
74
+ const sets = ["delivery_status = ?", ...names.map((n) => `${n} = ?`)].join(", ");
75
+ try {
76
+ return (db
77
+ .prepare(`UPDATE agent_messages SET ${sets} WHERE id = ? AND delivery_status = ?`)
78
+ .run(to, ...names.map((n) => fields[n]), id, from).changes === 1);
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ const NOW = "strftime('%Y-%m-%d %H:%M:%f', 'now')";
85
+ function stamp(id, column) {
86
+ try {
87
+ db.prepare(`UPDATE agent_messages SET ${column} = ${NOW} WHERE id = ?`).run(id);
88
+ }
89
+ catch {
90
+ }
91
+ }
92
+ function socketText(id, text, tag) {
93
+ return `${renderLeadMessage(id, text, tag)}\n\n${SOCKET_MESSAGE_FOOTER}`;
94
+ }
95
+ function acquirePaneLease(row, key) {
96
+ try {
97
+ db.prepare("INSERT OR IGNORE INTO actors (id, name, kind) VALUES (?, ?, 'agent')").run(row.from_actor, row.from_name);
98
+ db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at < datetime('now')").run(row.project_id, key);
99
+ const held = db
100
+ .prepare(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
101
+ VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
102
+ ON CONFLICT(project_id, lock_key) DO NOTHING
103
+ RETURNING expires_at`)
104
+ .get(row.project_id, key, row.from_actor, PANE_LEASE_TTL_SECONDS);
105
+ return held?.expires_at ?? null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ function releasePaneLease(row, key, expiresAt) {
112
+ try {
113
+ db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at = ?").run(row.project_id, key, expiresAt);
114
+ }
115
+ catch {
116
+ }
117
+ }
118
+ function hold(row, reason) {
119
+ try {
120
+ db.prepare("UPDATE agent_messages SET delivery_note = ? WHERE id = ? AND delivery_status = ?").run(reason, row.id, MESSAGE_STATUS.fallbackPending);
121
+ }
122
+ catch {
123
+ }
124
+ return { kind: "held", reason };
125
+ }
126
+ function failPending(row, reason) {
127
+ setStatus(row.id, MESSAGE_STATUS.fallbackPending, MESSAGE_STATUS.failed, { delivery_note: reason });
128
+ return { kind: "failed", reason };
129
+ }
130
+ function pasteFailure(err, pasted, buffered) {
131
+ const why = err instanceof Error ? err.message : String(err);
132
+ if (pasted) {
133
+ return err instanceof PaneOwnershipLost
134
+ ? `pasted, then the Enter was withheld because pane ownership was lost: ${why} The text may be on screen unsubmitted.`
135
+ : `pasted, then the Enter failed: ${why} The text is on screen unsubmitted.`;
136
+ }
137
+ if (buffered && err instanceof TmuxTimeoutError)
138
+ return `the paste timed out, so the text MAY be on screen unsubmitted: ${why}`;
139
+ return `nothing was pasted: ${why}`;
140
+ }
141
+ // One guarded pane attempt. The claim is the CAS that makes it at most one per message.
142
+ async function paneFallback(row, snapshot) {
143
+ let identity;
144
+ try {
145
+ identity = paneIdentity(JSON.parse(row.target_identity ?? ""));
146
+ }
147
+ catch {
148
+ return failPending(row, "the accepted target identity is unreadable, so no pane can be certified as the lead's");
149
+ }
150
+ const target = db.prepare("SELECT kind, command FROM agents WHERE id = ?").get(row.to_agent_id);
151
+ const ownership = currentOwnership(row.to_agent_id, identity, snapshot);
152
+ if (ownership === "unknown")
153
+ return hold(row, "the lead's pane ownership reads unknown");
154
+ if (ownership !== "live" || target === undefined) {
155
+ return failPending(row, `the lead pane this message was accepted for is ${ownership}; nothing is typed into a replacement`);
156
+ }
157
+ const key = `wake-pane:${identity.tmux_socket}:${identity.tmux_target}`;
158
+ const expiresAt = acquirePaneLease(row, key);
159
+ if (expiresAt === null)
160
+ return hold(row, "another delivery holds the lead's pane");
161
+ let wrote = false;
162
+ try {
163
+ const pane = identity.tmux_target;
164
+ if (paneInCopyMode(pane) === true)
165
+ return hold(row, "the lead's pane is in copy mode");
166
+ const classifier = paneClassifierFor(target.command);
167
+ if (!classifier)
168
+ return hold(row, "hive cannot classify the lead's screen");
169
+ const choice = classifier.choiceCheck(pane).awaitingChoice;
170
+ if (choice !== false)
171
+ return hold(row, choice ? "the lead's pane is waiting on a choice" : "the lead's pane could not be read");
172
+ if (holdsHumanInput(classifier.inputBoxState(pane)))
173
+ return hold(row, "the lead's input box holds unsubmitted text");
174
+ const claimed = (() => {
175
+ try {
176
+ return (db
177
+ .prepare(`UPDATE agent_messages SET delivery_status = ?, fallback_claimed_at = ${NOW}
178
+ WHERE id = ? AND delivery_status = ? AND NOT EXISTS (${messageEvidenceSql("1")})`)
179
+ .run(MESSAGE_STATUS.fallbackClaimed, row.id, MESSAGE_STATUS.fallbackPending).changes === 1);
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ })();
185
+ if (!claimed)
186
+ return { kind: "settled" };
187
+ let pasted = false;
188
+ let buffered = false;
189
+ wrote = true;
190
+ try {
191
+ await sendText(pane, renderLeadMessage(row.id, row.text, row.sender_tag ?? "", row.socket_attempt_at !== null), true, () => {
192
+ pasted = true;
193
+ stamp(row.id, "typed_at");
194
+ }, () => {
195
+ buffered = true;
196
+ }, () => requireStillOwned(row.to_agent_id, identity, `message #${row.id} fallback`));
197
+ }
198
+ catch (err) {
199
+ const reason = pasteFailure(err, pasted, buffered);
200
+ setStatus(row.id, MESSAGE_STATUS.fallbackClaimed, MESSAGE_STATUS.failed, { delivery_note: reason });
201
+ return { kind: "failed", reason };
202
+ }
203
+ setStatus(row.id, MESSAGE_STATUS.fallbackClaimed, MESSAGE_STATUS.complete);
204
+ return { kind: "typed" };
205
+ }
206
+ finally {
207
+ if (wrote)
208
+ await sleep(PANE_LEASE_SETTLE_MS);
209
+ releasePaneLease(row, key, expiresAt);
210
+ }
211
+ }
212
+ function earlierFallbackPending(row) {
213
+ return (db
214
+ .prepare(`SELECT 1 AS hit FROM agent_messages
215
+ WHERE from_actor = ? AND to_agent_id = ? AND id < ? AND delivery_status = ? AND ${RETAINED}`)
216
+ .get(row.from_actor, row.to_agent_id, row.id, MESSAGE_STATUS.fallbackPending) !== undefined);
217
+ }
218
+ const DO_NOT_RESEND = "do not resend";
219
+ // null means this send is not eligible, and agent_send's ordinary pane path runs unchanged.
220
+ export async function sendQuietLeadMessage(options) {
221
+ if (!eligible(options))
222
+ return null;
223
+ const { projectId, fromActor, target, text } = options;
224
+ const identity = paneIdentity(target);
225
+ const tag = senderTag(projectId, fromActor);
226
+ const session = leadSession(target.id, target.pane_pid);
227
+ const { id } = storeLeadMessage(projectId, fromActor, target.id, text);
228
+ const long = text.length > LEAD_MESSAGE_THRESHOLD;
229
+ const base = { agent_id: target.id, name: target.name, message_id: id, ...(long ? { shortened: true } : {}) };
230
+ const shortNote = (channel, redelivered = false) => long ? ` ${shortenedSendNote(id, renderLeadMessage(id, text, tag, redelivered).length, channel)}` : "";
231
+ const body = socketText(id, text, tag);
232
+ const skip = !registeredSocket(target)
233
+ ? "no messaging socket registered for this lead pane"
234
+ : body.includes(CROSS_SESSION_CLOSE)
235
+ ? "the text contains the envelope's closing tag"
236
+ : null;
237
+ // One statement makes the row visible to the scheduler, already in its route's status.
238
+ db.prepare(`UPDATE agent_messages SET delivery_status = ?, delivery_method = ?, socket_attempt_at = ${skip === null ? NOW : "NULL"},
239
+ delivery_note = ?, target_identity = ?, sender_tag = ? WHERE id = ?`).run(skip === null ? MESSAGE_STATUS.socketPending : MESSAGE_STATUS.fallbackPending, skip === null ? MESSAGE_METHOD.socket : MESSAGE_METHOD.pty, skip, JSON.stringify(identity), tag, id);
240
+ if (skip === null) {
241
+ const posted = await postClaudeWake({
242
+ socketPath: target.claude_messaging_socket,
243
+ senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
244
+ text: body,
245
+ beforeWrite: () => {
246
+ requireStillOwned(target.id, identity, `message #${id} socket delivery`);
247
+ const now = db
248
+ .prepare(`SELECT a.status, a.kind, a.pane_pid, a.claude_messaging_socket AS socket, a.claude_messaging_pane_pid AS pid,
249
+ m.delivery_status
250
+ FROM agents a, agent_messages m WHERE a.id = ? AND m.id = ?`)
251
+ .get(target.id, id);
252
+ return (now !== undefined &&
253
+ now.status === "running" &&
254
+ now.kind === "lead" &&
255
+ now.socket === target.claude_messaging_socket &&
256
+ now.pid === identity.pane_pid &&
257
+ now.pane_pid === identity.pane_pid &&
258
+ now.delivery_status === MESSAGE_STATUS.socketPending &&
259
+ leadSession(target.id, identity.pane_pid) === session);
260
+ },
261
+ });
262
+ if (posted) {
263
+ return {
264
+ ...base,
265
+ sent: true,
266
+ pending: true,
267
+ delivery_method: MESSAGE_METHOD.socket,
268
+ confirmed: false,
269
+ note: `Posted locally to the lead's messaging socket as message #${id}, awaiting prompt confirmation; ${DO_NOT_RESEND}. ` +
270
+ "sent means handed to the transport, not read: if no prompt confirms it, hive types it into the lead's pane once." +
271
+ shortNote("socket"),
272
+ };
273
+ }
274
+ setStatus(id, MESSAGE_STATUS.socketPending, MESSAGE_STATUS.fallbackPending, {
275
+ delivery_method: MESSAGE_METHOD.ptyAfterSocketTimeout,
276
+ delivery_note: "the socket post failed or timed out",
277
+ });
278
+ }
279
+ const row = readRow(id);
280
+ const method = (row.delivery_method ?? MESSAGE_METHOD.pty);
281
+ const outcome = earlierFallbackPending(row)
282
+ ? hold(row, "an earlier message from this sender to this lead is still awaiting its pane fallback, and this one follows it")
283
+ : await paneFallback(row);
284
+ if (outcome.kind === "typed") {
285
+ const note = shortNote("pane", row.socket_attempt_at !== null).trim();
286
+ return { ...base, sent: true, pending: false, delivery_method: method, ...(note ? { note } : {}) };
287
+ }
288
+ if (outcome.kind === "held") {
289
+ return {
290
+ ...base,
291
+ sent: false,
292
+ pending: true,
293
+ delivery_method: method,
294
+ note: `Not typed yet: ${outcome.reason}. Message #${id} is accepted and stored, and hive retries it from the ` +
295
+ `scheduler; do NOT resubmit it. agent_message_get(${id}) shows its delivery state.` +
296
+ shortNote("socket"),
297
+ };
298
+ }
299
+ if (outcome.kind === "settled") {
300
+ const now = readRow(id);
301
+ return {
302
+ ...base,
303
+ sent: true,
304
+ pending: now?.delivery_status !== MESSAGE_STATUS.complete,
305
+ delivery_method: method,
306
+ confirmed: now?.confirmed_at != null,
307
+ note: `Message #${id} was settled by another delivery path; ${DO_NOT_RESEND}. agent_message_get(${id}) shows its state.`,
308
+ };
309
+ }
310
+ throw new Error(`[agent_send:quiet-fallback-failed] message #${id} to ${target.name}: ${outcome.reason} hive will not type it again. ` +
311
+ `Do NOT resend blindly: read the lead's pane first, and agent_message_get(${id}) shows the recorded state.`);
312
+ }
313
+ function graceExpired(row, actorId, panePid) {
314
+ const firstStop = db
315
+ .prepare("SELECT MIN(created_at) AS t FROM agent_state_log WHERE actor_id = ? AND event = 'stop' AND created_at >= ?")
316
+ .get(actorId, row.socket_attempt_at).t;
317
+ let from = firstStop;
318
+ if (from === null) {
319
+ const turn = readLeadTurnState(row.to_agent_id);
320
+ if (turn !== null && turn.state === "working" && turn.pane_pid === panePid)
321
+ return false;
322
+ from = row.socket_attempt_at;
323
+ }
324
+ return db.prepare("SELECT (julianday('now') - julianday(?)) * 86400 >= ? AS due").get(from, CONFIRM_GRACE_SECONDS).due === 1;
325
+ }
326
+ function confirmFromPrompts() {
327
+ db.prepare(`UPDATE agent_messages SET confirmed_at = (${messageEvidenceSql("MIN(ev.created_at)")}), delivery_status = ?
328
+ WHERE delivery_status IS NOT NULL AND delivery_status != ? AND confirmed_at IS NULL AND ${RETAINED}
329
+ AND EXISTS (${messageEvidenceSql("1")})`).run(MESSAGE_STATUS.complete, MESSAGE_STATUS.complete);
330
+ }
331
+ function expireGrace() {
332
+ const pending = db
333
+ .prepare(`SELECT agent_messages.* FROM agent_messages WHERE delivery_status = ? AND ${RETAINED} ORDER BY id`)
334
+ .all(MESSAGE_STATUS.socketPending);
335
+ for (const row of pending) {
336
+ try {
337
+ const target = db.prepare("SELECT actor_id FROM agents WHERE id = ?").get(row.to_agent_id);
338
+ const panePid = JSON.parse(row.target_identity ?? "{}").pane_pid ?? "";
339
+ if (target === undefined || !graceExpired(row, target.actor_id, panePid))
340
+ continue;
341
+ db.prepare(`UPDATE agent_messages SET delivery_status = ?, delivery_method = ?, delivery_note = ?
342
+ WHERE id = ? AND delivery_status = ? AND NOT EXISTS (${messageEvidenceSql("1")})`).run(MESSAGE_STATUS.fallbackPending, MESSAGE_METHOD.ptyAfterSocketTimeout, "no prompt confirmed the socket post within its grace", row.id, MESSAGE_STATUS.socketPending);
343
+ }
344
+ catch {
345
+ }
346
+ }
347
+ }
348
+ // Runs inside tick's try; each row's failure is contained so one bad delivery cannot strand the rest.
349
+ export async function retryQuietLeadMessages(snapshot) {
350
+ try {
351
+ confirmFromPrompts();
352
+ }
353
+ catch {
354
+ }
355
+ expireGrace();
356
+ let due;
357
+ try {
358
+ due = db
359
+ .prepare(`SELECT agent_messages.* FROM agent_messages WHERE delivery_status = ? AND ${RETAINED} ORDER BY id`)
360
+ .all(MESSAGE_STATUS.fallbackPending);
361
+ }
362
+ catch {
363
+ return;
364
+ }
365
+ const blocked = new Set();
366
+ for (const row of due) {
367
+ const pair = `${row.from_actor}\u0000${row.to_agent_id}`;
368
+ if (blocked.has(pair))
369
+ continue;
370
+ try {
371
+ const outcome = await paneFallback(row, snapshot);
372
+ if (outcome.kind === "held")
373
+ blocked.add(pair);
374
+ }
375
+ catch {
376
+ blocked.add(pair);
377
+ }
378
+ }
379
+ }
package/dist/leadState.js CHANGED
@@ -3,6 +3,12 @@ import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
3
3
  export function readLeadTurnState(agentId) {
4
4
  return db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null;
5
5
  }
6
+ export function leadSessionEnded(row, ownership) {
7
+ if ((ownership !== "gone" && ownership !== "reissued") || row.pane_pid === "")
8
+ return null;
9
+ const turn = readLeadTurnState(row.id);
10
+ return turn?.last_event === "session_end" && turn.pane_pid === row.pane_pid ? { ended_at: turn.changed_at } : null;
11
+ }
6
12
  function isHiveFirstMessage(payload) {
7
13
  if (typeof payload.prompt !== "string")
8
14
  return false;
@@ -44,7 +50,8 @@ const applyInTransaction = db.transaction((agentId, panePid, event, payload, sub
44
50
  cur.pane_pid === next.pane_pid &&
45
51
  cur.session_id === next.session_id &&
46
52
  cur.state === next.state &&
47
- cur.idle_seq === next.idle_seq) {
53
+ cur.idle_seq === next.idle_seq &&
54
+ (event !== "session_end" || cur.last_event === "session_end")) {
48
55
  return;
49
56
  }
50
57
  db.prepare(`INSERT INTO lead_turn_state (agent_id, pane_pid, session_id, state, idle_seq, last_event, changed_at)
package/dist/portfolio.js CHANGED
@@ -3,7 +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 { readLeadTurnState } from "./leadState.js";
6
+ import { leadSessionEnded, readLeadTurnState } from "./leadState.js";
7
7
  import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
8
8
  export const PORTFOLIO_REASONS = [
9
9
  "needs_human",
@@ -88,14 +88,17 @@ function projectRow(project, now, snapshot) {
88
88
  const workers = { working: 0, idle: 0, needs_input: 0, other: 0, unreachable: 0, unconfirmed: 0 };
89
89
  let liveWorking = 0;
90
90
  let liveWaiting = 0;
91
- let lead = { state: "none", agent_id: null, turn: "unknown" };
91
+ let lead = { state: "none", agent_id: null, turn: "unknown", ended_at: null };
92
92
  for (const a of agents) {
93
93
  if (a.kind === "lead") {
94
- const live = liveness(a);
94
+ const ownership = rowOwnership(a, snapshot);
95
+ const live = ownershipLiveness(ownership);
96
+ const ended = leadSessionEnded(a, ownership);
95
97
  lead = {
96
- state: live === true ? "alive" : live === false ? "dead_pane" : "unknown",
98
+ state: live === true ? "alive" : live === false ? (ended ? "dormant" : "dead_pane") : "unknown",
97
99
  agent_id: a.id,
98
100
  turn: live === true ? leadTurnFor(a.id, a.pane_pid) : "unknown",
101
+ ended_at: ended?.ended_at ?? null,
99
102
  };
100
103
  continue;
101
104
  }
@@ -135,7 +138,7 @@ function projectRow(project, now, snapshot) {
135
138
  const found = new Set();
136
139
  if (tagged.length > 0)
137
140
  found.add("needs_human");
138
- if (lead.state === "dead_pane" && (todos.in_progress > 0 || wakeCounts.pending > 0))
141
+ if ((lead.state === "dead_pane" || lead.state === "dormant") && (todos.in_progress > 0 || wakeCounts.pending > 0))
139
142
  found.add("dead_lead_pane");
140
143
  if (!rootExists && (activeTodos > 0 || wakeCounts.pending > 0))
141
144
  found.add("missing_root_with_work");
@@ -18,6 +18,7 @@ export const BUILT_IN_PROJECT_YML = Object.freeze({
18
18
  context_checkpoint_percent: null,
19
19
  lead_turn_budget: null,
20
20
  dashboard: false,
21
+ lead_sidebar: false,
21
22
  quiet_messaging: false,
22
23
  vars: Object.freeze({}),
23
24
  processes: Object.freeze({}),
@@ -199,6 +200,19 @@ function applyLayer(root, path, source, config, sources, warnings) {
199
200
  else
200
201
  warn(`dashboard must be true or false; ignoring "${String(value)}".`);
201
202
  }
203
+ if (has("lead_sidebar")) {
204
+ const value = root.lead_sidebar;
205
+ if (value == null) {
206
+ config.lead_sidebar = false;
207
+ sources.lead_sidebar = source;
208
+ }
209
+ else if (typeof value === "boolean") {
210
+ config.lead_sidebar = value;
211
+ sources.lead_sidebar = source;
212
+ }
213
+ else
214
+ warn(`lead_sidebar must be true or false; ignoring "${String(value)}".`);
215
+ }
202
216
  if (has("quiet_messaging")) {
203
217
  const value = root.quiet_messaging;
204
218
  if (value == null) {
@@ -2,6 +2,7 @@ import { existsSync, realpathSync } from "node:fs";
2
2
  import { join, sep } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { db } from "./db.js";
5
+ import { SECTION_STYLE, SPRITE, chevron, renderBoardPadsTodos, sectionScript } from "./dashboard.js";
5
6
  export const QUEEN_BRIEF_KEY = "queen:brief";
6
7
  const LANE_ORDER = ["waiting_on_you", "stuck", "moving", "quiet"];
7
8
  const LANE_RANK = { waiting_on_you: 0, stuck: 1, moving: 2, quiet: 3 };
@@ -158,6 +159,8 @@ function leadPill(p) {
158
159
  return `<span class="status status-ok">lead up</span>${turnPill(p)}`;
159
160
  case "dead_pane":
160
161
  return '<span class="status status-fail">lead dead</span>';
162
+ case "dormant":
163
+ return '<span class="status status-off">lead dormant</span>';
161
164
  case "unknown":
162
165
  return '<span class="status status-off">lead unknown</span>';
163
166
  default:
@@ -243,7 +246,7 @@ function quietList(list, asOf, href) {
243
246
  return ('<section class="card"><ul class="quiet-list">' +
244
247
  list
245
248
  .map((p) => {
246
- const sub = `${p.lead.state === "alive" ? (turnText(p) ? `lead up, ${turnText(p)}` : "lead up") : p.lead.state === "dead_pane" ? "lead dead" : p.lead.state === "unknown" ? "lead unknown" : "no lead"}, ${p.todos.open} open`;
249
+ const sub = `${p.lead.state === "alive" ? (turnText(p) ? `lead up, ${turnText(p)}` : "lead up") : p.lead.state === "dead_pane" ? "lead dead" : p.lead.state === "dormant" ? "lead dormant" : p.lead.state === "unknown" ? "lead unknown" : "no lead"}, ${p.todos.open} open`;
247
250
  return (`<li data-project="${p.id}"><span>${projectLink(p, href)}` +
248
251
  `<a class="sub" href="#row-${p.id}" data-jump="${p.id}">${esc(sub)}</a></span>` +
249
252
  `<span class="age">${esc(age(p.last_activity_at, asOf))}</span></li>`);
@@ -338,7 +341,7 @@ function briefSection(state, report, href, queenHomeId) {
338
341
  (picks ? `<ol>${picks}</ol>` : "") +
339
342
  `${driftLine(brief, report, queenHomeId)}</section>`);
340
343
  }
341
- const CSS = `
344
+ const CSS = `${SECTION_STYLE}
342
345
  :root { color-scheme: light;
343
346
  --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
344
347
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -350,7 +353,7 @@ const CSS = `
350
353
  --accent: #1f5fd0; --focus: #1f5fd0; --hover: #f1f4f8;
351
354
  --shadow: 0 1px 2px rgba(19, 26, 38, 0.06), 0 2px 8px rgba(19, 26, 38, 0.05);
352
355
  --card-border: transparent; --r-card: 14px; --r-ctl: 9px; --r-pill: 999px;
353
- --ease-out: cubic-bezier(0.23, 1, 0.32, 1); }
356
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1); --header-offset: 5rem; }
354
357
  @media (prefers-color-scheme: dark) {
355
358
  :root:not([data-theme="light"]) { color-scheme: dark;
356
359
  --bg: #101317; --panel: #171b21; --panel-sunken: #1c2129;
@@ -478,17 +481,22 @@ a.proj:hover { color: var(--accent); text-decoration: underline; text-underline-
478
481
  a.sub:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
479
482
  .turn { font-size: 0.75rem; color: var(--fg-muted); }
480
483
  .turn-work { color: var(--live); }
481
- .c-audit { margin-top: 1.5rem; padding-bottom: 0.85rem; }
484
+ .c-audit { margin-top: 1.5rem; }
485
+ .c-audit[open] { padding-bottom: 0.85rem; }
486
+ details.card:not([open]) > .card-head { padding-bottom: 0.9rem; }
482
487
  .c-audit .audit-list, .c-audit .brief-empty { padding: 0 1.1rem; }
483
488
  .audit-list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.4rem; font-size: 0.8125rem; }
484
489
  .audit-list li { display: flex; flex-wrap: wrap; gap: 0.15rem 0.75rem; align-items: baseline; }
485
490
  .audit-op { font-weight: 600; }
486
491
  .audit-sum { color: var(--fg-muted); min-width: 0; overflow-wrap: anywhere; }
487
492
  .d-grid { margin-top: 1.5rem; }
493
+ .card-head > .chev { margin-left: auto; }
494
+ .home { margin-top: 1.5rem; }
495
+ .home-head { padding: 0 0.2rem 0.6rem; }
488
496
  .d-grid .g-row:not(.g-head) { box-shadow: inset 3px 0 0 var(--lane, transparent); }
489
497
  .d-grid .g-row { scroll-margin-top: 6rem; transition: background-color 600ms ease; }
490
498
  .d-grid .g-row.is-flash { background: var(--live-bg); transition: none; }
491
- .empty { margin-top: 1.25rem; padding: 1.25rem 1.1rem; color: var(--fg-muted); }
499
+ .no-projects { margin-top: 1.25rem; padding: 1.25rem 1.1rem; color: var(--fg-muted); }
492
500
  @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
493
501
  `;
494
502
  const SCRIPT = `(function () {
@@ -504,12 +512,17 @@ const SCRIPT = `(function () {
504
512
  var row = document.getElementById("row-" + j.getAttribute("data-jump"));
505
513
  if (!row) return;
506
514
  e.preventDefault();
515
+ var box = row.closest("details");
516
+ if (box) box.open = true;
507
517
  var still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
508
518
  row.scrollIntoView({ behavior: still ? "auto" : "smooth", block: "center" });
509
519
  row.classList.add("is-flash");
510
520
  setTimeout(function () { row.classList.remove("is-flash"); }, 900);
511
521
  });
512
522
  })();`;
523
+ const SECTIONS_SCRIPT = `(function () {
524
+ ${sectionScript("hive-queen-state")}
525
+ })();`;
513
526
  const MARK = '<symbol id="mark" viewBox="0 0 32 32"><path d="M16 3.2 27 9.6v12.8L16 28.8 5 22.4V9.6Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M16 11 21 14v6l-5 3-5-3v-6Z" fill="currentColor" fill-opacity="0.22" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></symbol>';
514
527
  export const QUEEN_GENERATED_MARKER = "data-generated";
515
528
  function auditSection(audit, report) {
@@ -525,7 +538,7 @@ function auditSection(audit, report) {
525
538
  .join("") +
526
539
  "</ul>"
527
540
  : '<p class="brief-empty">No queen actions recorded yet.</p>';
528
- return `<section class="card c-audit"><div class="card-head"><h2>Recent queen actions</h2><span class="card-sub">the newest writes the queen made into other projects</span></div>${body}</section>`;
541
+ return `<details class="card c-audit" id="queen-recent-actions" open><summary class="card-head"><h2>Recent queen actions</h2><span class="card-sub">the newest writes the queen made into other projects</span>${chevron()}</summary>${body}</details>`;
529
542
  }
530
543
  export function renderQueenDashboard(report, briefState, href = (p) => dashboardHref(p.root), audit = [], queenHomeId = null) {
531
544
  const asOf = report.as_of;
@@ -552,22 +565,26 @@ export function renderQueenDashboard(report, briefState, href = (p) => dashboard
552
565
  `<span class="count">${report.totals.lanes[lane]}</span></h2></div>` +
553
566
  `<p class="lane-rule">${LANE_RULE[lane]}</p>${body}</section>`);
554
567
  }).join("");
568
+ const home = queenHomeId === null
569
+ ? ""
570
+ : `<div class="home"><div class="home-head"><h2>Queen home project</h2></div>${renderBoardPadsTodos(queenHomeId)}</div>`;
555
571
  const main = n === 0
556
- ? `${briefSection(briefState, report, href, queenHomeId)}<section class="card empty"><h2>No projects registered</h2><p>Run <code>hive lead</code> in a project folder to register it, and it will appear here.</p></section>`
572
+ ? `${briefSection(briefState, report, href, queenHomeId)}<section class="card no-projects"><h2>No projects registered</h2><p>Run <code>hive lead</code> in a project folder to register it, and it will appear here.</p></section>`
557
573
  : `${briefSection(briefState, report, href, queenHomeId)}<div class="lanes">${lanes}</div>` +
558
- `<section class="card d-grid"><div class="card-head"><h2>Every project <span class="count">${n}</span></h2>` +
559
- '<span class="card-sub">in lane order; a name opens that project’s dashboard</span></div>' +
574
+ `<details class="card d-grid" id="queen-every-project" open><summary class="card-head"><h2>Every project <span class="count">${n}</span></h2>` +
575
+ `<span class="card-sub">in lane order; a name opens that project’s dashboard</span>${chevron()}</summary>` +
560
576
  `<div class="grid" role="table">${GRID_HEAD}${ordered.map((p) => gridRow(p, asOf, href)).join("")}</div>` +
561
- '<p class="freshnote">Every row here is read from the store when the page is written. Only the picks are written by a model, which is why they carry a time.</p></section>' +
562
- auditSection(audit, report);
577
+ '<p class="freshnote">Every row here is read from the store when the page is written. Only the picks are written by a model, which is why they carry a time.</p></details>';
578
+ const page = main + home + (n === 0 ? "" : auditSection(audit, report));
563
579
  return ('<!doctype html><html lang="en"><head><meta charset="utf-8">' +
564
580
  '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
565
581
  `<title>All projects</title><style>${CSS}</style></head><body>` +
582
+ SPRITE +
566
583
  `<svg style="position:absolute;width:0;height:0" aria-hidden="true"><defs>${MARK}</defs></svg>` +
567
- '<header class="topbar"><div class="wrap topbar-inner"><div class="brand">' +
584
+ '<header class="topbar" id="topbar"><div class="wrap topbar-inner"><div class="brand">' +
568
585
  '<svg class="mark" role="img" aria-label="hive"><use href="#mark"/></svg><div><h1>All projects</h1>' +
569
586
  `<p class="generated" ${QUEEN_GENERATED_MARKER}>Live rows as of ${esc(asOf)} UTC. ${n} project${n === 1 ? "" : "s"} on this machine.</p></div></div>` +
570
587
  '<label class="live-toggle" title="Off stops this page reloading. The scheduler still rewrites the file.">' +
571
588
  '<input type="checkbox" id="autoreload"> Reload every minute</label></div></header>' +
572
- `<main class="wrap">${main}</main><script>${SCRIPT}</script></body></html>\n`);
589
+ `<main class="wrap">${page}</main><script>${SCRIPT}\n${SECTIONS_SCRIPT}</script></body></html>\n`);
573
590
  }