@cmgmyr/hive 1.10.1 → 1.11.1

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/db.js CHANGED
@@ -430,6 +430,150 @@ ALTER TABLE agent_messages ADD COLUMN sender_tag TEXT;
430
430
  CREATE INDEX idx_agent_messages_delivery ON agent_messages(delivery_status, id) WHERE delivery_status IS NOT NULL;
431
431
  `,
432
432
  `ALTER TABLE agents ADD COLUMN todo_id INTEGER REFERENCES todos(id) ON DELETE SET NULL;`,
433
+ `
434
+ ALTER TABLE lead_turn_state ADD COLUMN snapshot_pane_pid TEXT NOT NULL DEFAULT '';
435
+ ALTER TABLE lead_turn_state ADD COLUMN snapshot_session_id TEXT NOT NULL DEFAULT '';
436
+ ALTER TABLE lead_turn_state ADD COLUMN prompt_seq INTEGER NOT NULL DEFAULT 0;
437
+ ALTER TABLE lead_turn_state ADD COLUMN human_prompt_seq INTEGER NOT NULL DEFAULT 0;
438
+ ALTER TABLE lead_turn_state ADD COLUMN human_prompt_at TEXT;
439
+ ALTER TABLE lead_turn_state ADD COLUMN transcript_path TEXT NOT NULL DEFAULT '';
440
+ ALTER TABLE lead_turn_state ADD COLUMN stop_prompt_seq INTEGER;
441
+ ALTER TABLE lead_turn_state ADD COLUMN stop_background TEXT;
442
+ ALTER TABLE lead_turn_state ADD COLUMN stop_at TEXT;
443
+
444
+ CREATE TABLE lead_handoffs (
445
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
446
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
447
+ lead_agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
448
+ pane_target TEXT NOT NULL,
449
+ tmux_socket TEXT NOT NULL DEFAULT '',
450
+ predecessor_pane_pid TEXT NOT NULL,
451
+ predecessor_session_id TEXT NOT NULL,
452
+ reason TEXT NOT NULL CHECK (reason IN ('warn', 'stop')),
453
+ state TEXT NOT NULL CHECK (state IN ('pending', 'wind_down', 'requested', 'grace', 'postponed',
454
+ 'respawning', 'started', 'completed', 'failed', 'ambiguous')),
455
+ pass INTEGER NOT NULL DEFAULT 1,
456
+ request_reason TEXT CHECK (request_reason IN ('warn', 'stop')),
457
+ pass_started_at TEXT,
458
+ request_wake_id INTEGER,
459
+ attempt INTEGER NOT NULL DEFAULT 0,
460
+ owner_token TEXT,
461
+ owner_pid INTEGER,
462
+ grace_seconds INTEGER,
463
+ grace_started_at TEXT,
464
+ human_prompt_baseline INTEGER,
465
+ pad_id INTEGER,
466
+ pad_revision INTEGER,
467
+ pad_length INTEGER,
468
+ pad_sha256 TEXT,
469
+ respawn_claimed_at TEXT,
470
+ predecessor_turns INTEGER,
471
+ hold_since TEXT,
472
+ hold_released_at TEXT,
473
+ blocked_reason TEXT,
474
+ successor_pane_pid TEXT,
475
+ successor_session_id TEXT,
476
+ delivered_pad_id INTEGER,
477
+ delivered_pad_revision INTEGER,
478
+ started_at TEXT,
479
+ completed_at TEXT,
480
+ failure TEXT,
481
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
482
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
483
+ );
484
+ CREATE UNIQUE INDEX idx_lead_handoffs_epoch
485
+ ON lead_handoffs(project_id, lead_agent_id, predecessor_pane_pid, predecessor_session_id);
486
+ CREATE UNIQUE INDEX idx_lead_handoffs_active
487
+ ON lead_handoffs(lead_agent_id) WHERE state NOT IN ('completed', 'failed');
488
+ `,
489
+ `
490
+ CREATE TRIGGER fence_handoff_held_claim
491
+ BEFORE UPDATE OF fire_count, delivery_method ON wakes
492
+ FOR EACH ROW
493
+ WHEN (NEW.fire_count > OLD.fire_count
494
+ OR (NEW.delivery_method IS 'pty-after-socket-timeout'
495
+ AND OLD.delivery_method IS NOT 'pty-after-socket-timeout'
496
+ AND NEW.typed_at IS NULL))
497
+ AND EXISTS (
498
+ SELECT 1 FROM lead_handoffs h
499
+ WHERE h.lead_agent_id = (SELECT a.id FROM agents a
500
+ WHERE a.actor_id = NEW.deliver_actor
501
+ ORDER BY (a.status = 'running') DESC,
502
+ a.id DESC
503
+ LIMIT 1)
504
+ AND h.state NOT IN ('completed', 'failed')
505
+ AND h.request_wake_id IS NOT NEW.id
506
+ AND (h.state = 'grace'
507
+ OR (h.hold_released_at IS NULL AND (
508
+ h.state IN ('respawning', 'ambiguous', 'wind_down')
509
+ OR (h.state = 'requested'
510
+ AND (h.reason = 'stop' OR h.pass >= 2))
511
+ OR (h.state = 'postponed' AND h.reason = 'stop'))))
512
+ AND (julianday('now') - julianday(h.updated_at)) * 86400 < 3600)
513
+ BEGIN
514
+ SELECT RAISE(IGNORE);
515
+ END;
516
+ `,
517
+ `DROP TRIGGER fence_handoff_held_claim;`,
518
+ `
519
+ CREATE TRIGGER fence_handoff_held_claim
520
+ BEFORE UPDATE OF fire_count, delivery_method ON wakes
521
+ FOR EACH ROW
522
+ WHEN (NEW.fire_count > OLD.fire_count
523
+ OR (NEW.delivery_method IS 'pty-after-socket-timeout'
524
+ AND OLD.delivery_method IS NOT 'pty-after-socket-timeout'
525
+ AND NEW.typed_at IS NULL))
526
+ AND EXISTS (
527
+ SELECT 1 FROM lead_handoffs h
528
+ WHERE h.lead_agent_id = (SELECT a.id FROM agents a
529
+ WHERE a.actor_id = NEW.deliver_actor
530
+ ORDER BY (a.status = 'running') DESC,
531
+ a.id DESC
532
+ LIMIT 1)
533
+ AND h.state NOT IN ('completed', 'failed')
534
+ AND h.request_wake_id IS NOT NEW.id
535
+ AND (h.state IN ('grace', 'started')
536
+ OR (h.hold_released_at IS NULL AND (
537
+ h.state IN ('respawning', 'ambiguous', 'wind_down')
538
+ OR (h.state = 'requested'
539
+ AND (h.reason = 'stop' OR h.pass >= 2))
540
+ OR (h.state = 'postponed' AND h.reason = 'stop'))))
541
+ AND (julianday('now') - julianday(h.updated_at)) * 86400 < 3600)
542
+ BEGIN
543
+ SELECT RAISE(IGNORE);
544
+ END;
545
+ `,
546
+ `
547
+ CREATE TABLE questions (
548
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
549
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
550
+ asker TEXT NOT NULL,
551
+ kind TEXT NOT NULL CHECK (kind IN ('choice', 'free_text')),
552
+ question TEXT NOT NULL CHECK (trim(question) != ''),
553
+ header TEXT NOT NULL CHECK (trim(header) != ''),
554
+ options TEXT,
555
+ default_answer TEXT NOT NULL CHECK (trim(default_answer) != ''),
556
+ ref_kind TEXT CHECK (ref_kind IN ('todo', 'pr')),
557
+ ref_number INTEGER CHECK (ref_number > 0),
558
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'answered', 'defaulted', 'withdrawn')),
559
+ answer TEXT,
560
+ notes TEXT,
561
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
562
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
563
+ updated_by TEXT NOT NULL,
564
+ answered_by TEXT,
565
+ CHECK ((ref_kind IS NULL) = (ref_number IS NULL)),
566
+ CHECK ((kind = 'choice') = (options IS NOT NULL))
567
+ );
568
+ CREATE UNIQUE INDEX idx_questions_pending_ref ON questions(project_id, ref_kind, ref_number)
569
+ WHERE state = 'pending' AND ref_kind IS NOT NULL;
570
+ CREATE INDEX idx_questions_project_state ON questions(project_id, state, id);
571
+ `,
572
+ `
573
+ ALTER TABLE queen_audit ADD COLUMN outcome TEXT NOT NULL DEFAULT 'confirmed'
574
+ CHECK (outcome IN ('confirmed', 'refused'));
575
+ ALTER TABLE queen_audit ADD COLUMN problem TEXT NOT NULL DEFAULT '';
576
+ `,
433
577
  ];
434
578
  export function storeSchemaAhead(database) {
435
579
  try {
package/dist/help.js CHANGED
@@ -40,6 +40,9 @@ KV — small shared status values (with optional TTL)
40
40
  LEASES — advisory expiring claims on shared state
41
41
  lease_acquire, lease_release
42
42
 
43
+ QUESTIONS — open questions for the human, recorded in the store
44
+ question_ask, question_get, question_list, question_update, question_answer
45
+
43
46
  PROJECTS — scope management
44
47
  project_list, project_add, project_select, project_prune
45
48
 
@@ -246,6 +249,9 @@ Delivery contract:
246
249
  and the next action. Plain English, no markup.
247
250
  Waking your OWN lead pane is the exception: it is not cold, so carry the
248
251
  action and the ids and point at where the detail lives.
252
+ With lead_turn_budget.auto_handoff on, a lead past its warn budget gets
253
+ a "hive lead handoff request #N" wake: follow its steps, then run
254
+ hive lead-handoff. A human prompt during the grace postpones it.
249
255
 
250
256
  Receiving wake-ups:
251
257
  Spawned workers can always receive (deliver_to their name or id).
@@ -378,6 +384,27 @@ todo_list without marking anything done.`,
378
384
 
379
385
  Use kv for values another session needs to discover: current branch, a port,
380
386
  a phase flag. Use pads for anything longer than a line or two.`,
387
+ questions: `QUESTIONS — open questions for the human, recorded in the store
388
+
389
+ question_ask(kind, question, header, default_answer, options?, ref?) —
390
+ record a question. kind=choice takes 2-4 options (label, description,
391
+ recommended; exactly one recommended, and default_answer is its label).
392
+ kind=free_text takes no options. header is 12 characters or fewer. ref
393
+ ({kind: "todo"|"pr", number}) is the dedupe key: asking again with a ref
394
+ that has a pending question in this project updates that row. The
395
+ receipt lists similar pending questions so you can question_update one.
396
+ question_get(question_id) — one question, any state, with its answer
397
+ question_list() — pending questions, then defaulted ones, oldest first
398
+ question_update(question_id, ...) — edit a pending question's text, or set
399
+ state to defaulted (you acted on your default) or withdrawn (moot)
400
+ question_answer(question_id, answer, notes?) — record the human's answer
401
+ to a pending or defaulted question
402
+
403
+ hive stores the question; it never puts it to the human. Render it in your
404
+ own turn. Answering queues a wake to the asker carrying the question,
405
+ answer and notes, unless the answerer is the asker or the asker cannot be
406
+ reached; the answer is kept either way. Nothing expires or defaults on its
407
+ own: only the asker or the human moves a question out of pending.`,
381
408
  leases: `LEASES — advisory expiring claims on shared state
382
409
 
383
410
  lease_acquire(key, ttl_seconds) — non-blocking; returns the holder if
package/dist/hook.js CHANGED
@@ -177,6 +177,13 @@ async function stopProcessesForEndedLead(actorId, payload) {
177
177
  .get(actorId);
178
178
  if (!row)
179
179
  return;
180
+ // A deliberate handoff's predecessor ending is not the lead ending; its successor keeps the crew.
181
+ const lead = db.prepare("SELECT id FROM agents WHERE actor_id = ? AND kind = 'lead' AND status = 'running'").get(actorId);
182
+ if (lead !== undefined) {
183
+ const { predecessorFence } = await import("./leadState.js");
184
+ if (predecessorFence(lead.id, payload.session_id))
185
+ return;
186
+ }
180
187
  // Imported here and nowhere above: harnesses.ts itself pulls tmux at module scope, and every
181
188
  // other event in this file runs on every turn of every session without needing any of it.
182
189
  const { harnessFor } = await import("./harnesses.js");
@@ -313,7 +320,8 @@ try {
313
320
  if (!guarded)
314
321
  state = applyWorkerTransition(actorId, event);
315
322
  // A lead's turn state is its own table; agents.agent_state stays worker-only.
316
- if (process.env.HIVE_LEAD === "1" && (event === "prompt" || event === "stop" || event === "session_end")) {
323
+ if (process.env.HIVE_LEAD === "1" &&
324
+ ["prompt", "stop", "session_end"].includes(event)) {
317
325
  try {
318
326
  const { applyLeadHook } = await import("./leadState.js");
319
327
  applyLeadHook(actorId, event, readPayload(), () => waitingOnSubagents(actorId, readPayload()));
@@ -321,6 +329,18 @@ try {
321
329
  catch {
322
330
  }
323
331
  }
332
+ if (event === "stop" && process.env.HIVE_LEAD === "1") {
333
+ try {
334
+ const started = db
335
+ .prepare(`SELECT 1 AS hit FROM lead_handoffs h JOIN agents a ON a.id = h.lead_agent_id
336
+ WHERE a.actor_id = ? AND a.kind = 'lead' AND h.state = 'started'`)
337
+ .get(actorId);
338
+ if (started !== undefined)
339
+ (await import("./leadHandoff.js")).completeHandoffOnStop(actorId);
340
+ }
341
+ catch {
342
+ }
343
+ }
324
344
  if (event === "session_start" && process.env.HIVE_LEAD === "1") {
325
345
  try {
326
346
  await registerLeadMessagingSocket(actorId);
@@ -340,7 +360,17 @@ try {
340
360
  reconcileTranscriptPath(actorId, hookPayload);
341
361
  db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
342
362
  }
343
- record(actorId, event, state ?? UNCHANGED);
363
+ let logState = state ?? UNCHANGED;
364
+ if (event === "prompt" && process.env.HIVE_LEAD === "1") {
365
+ try {
366
+ const { isHiveFirstMessage, FIRST_MESSAGE_LOG_STATE } = await import("./leadState.js");
367
+ if (isHiveFirstMessage(readPayload()))
368
+ logState = FIRST_MESSAGE_LOG_STATE;
369
+ }
370
+ catch {
371
+ }
372
+ }
373
+ record(actorId, event, logState);
344
374
  }
345
375
  }
346
376
  catch {
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerTodos } from "./tools/todos.js";
10
10
  import { registerKv } from "./tools/kv.js";
11
11
  import { registerLeases } from "./tools/leases.js";
12
12
  import { registerWakes } from "./tools/wakes.js";
13
+ import { registerQuestions } from "./tools/questions.js";
13
14
  import { registerQueenAudit } from "./tools/queenAudit.js";
14
15
  import { registerPrompts } from "./prompts.js";
15
16
  import { schedulerIntervalMs, startScheduler } from "./scheduler.js";
@@ -34,6 +35,7 @@ registerTodos(server);
34
35
  registerKv(server);
35
36
  registerLeases(server);
36
37
  registerWakes(server);
38
+ registerQuestions(server);
37
39
  registerQueenAudit(server);
38
40
  registerPrompts(server);
39
41
  startScheduler(schedulerIntervalMs());
package/dist/kickoff.js CHANGED
@@ -97,6 +97,20 @@ async function digest(projectPath, profile, warnings) {
97
97
  lines.push("", "Standing process for this project: run `hive runbook`.");
98
98
  return truncate(lines.join("\n"), CONTEXT_BUDGET);
99
99
  }
100
+ // Lead-only: a worker returned above, and a session hive did not start as a lead gets nothing.
101
+ async function leadHandoffText() {
102
+ const actor = process.env.HIVE_AGENT_ID;
103
+ if (process.env.HIVE_LEAD !== "1" || !actor)
104
+ return null;
105
+ try {
106
+ const { migrate } = await import("./db.js");
107
+ migrate();
108
+ return (await import("./leadHandoff.js")).handoffInjection(actor);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
100
114
  // Shared with `hive lead`, which passes the first message on the command line only when this holds.
101
115
  export async function kickoffGate(dir) {
102
116
  const { activeProfile, DEFAULT_LEAD_BRANCHES, loadProjectYml } = await import("./projectYml.js");
@@ -128,13 +142,20 @@ export async function evaluate(cwd, opts = {}) {
128
142
  catch {
129
143
  return { fired: false, reason: "cwd does not exist" };
130
144
  }
145
+ const handoff = await leadHandoffText();
146
+ const handoffOnly = (warnings) => ({
147
+ fired: true,
148
+ payload: JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: handoff } }),
149
+ warnings,
150
+ });
131
151
  const gate = await kickoffGate(dir);
132
152
  if (!gate.ok)
133
- return { fired: false, reason: gate.reason, warnings: gate.warnings };
153
+ return handoff === null ? { fired: false, reason: gate.reason, warnings: gate.warnings } : handoffOnly(gate.warnings);
134
154
  const { config, warnings, sources, profile } = gate;
135
155
  const context = await digest(dir, profile, warnings);
136
- if (context == null)
137
- return { fired: false, reason: "not a registered hive project root", warnings };
156
+ if (context == null) {
157
+ return handoff === null ? { fired: false, reason: "not a registered hive project root", warnings } : handoffOnly(warnings);
158
+ }
138
159
  // Codex's SessionStartHookSpecificOutputWire is additionalProperties:false and permits only
139
160
  // hookEventName/additionalContext - initialUserMessage inside it fails the whole payload
140
161
  // ("SessionStart Failed"), silently dropping the board too. Measured, todo 567 comment 1864.
@@ -145,13 +166,15 @@ export async function evaluate(cwd, opts = {}) {
145
166
  const build = (text) => JSON.stringify({
146
167
  hookSpecificOutput: {
147
168
  hookEventName: "SessionStart",
148
- additionalContext: text,
169
+ additionalContext: handoff === null ? text : `${text}\n\n${handoff}`,
149
170
  ...(hookMessage === null ? {} : { initialUserMessage: hookMessage }),
150
171
  },
151
172
  });
152
173
  let payload = build(context);
153
- if (payload.length > OUTPUT_BUDGET) {
154
- const overflow = payload.length - OUTPUT_BUDGET;
174
+ // The handoff rides outside the digest budget: only the digest is ever cut to fit.
175
+ const budget = OUTPUT_BUDGET + (handoff === null ? 0 : JSON.stringify(handoff).length);
176
+ if (payload.length > budget) {
177
+ const overflow = payload.length - budget;
155
178
  payload = build(truncate(context, Math.max(0, context.length - overflow - 32)));
156
179
  }
157
180
  return { fired: true, payload, warnings, firstMessage: first };