@cmgmyr/hive 1.11.0 → 1.11.2

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/README.md CHANGED
@@ -70,6 +70,7 @@ hive is a personal daily-driver tool, released low-key. It is single-user by des
70
70
  | [Dashboard](docs/dashboard.md) | The generated dashboard: enabling it, where it lives, what it shows |
71
71
  | [Install details](docs/install.md) | The interpreter pin, iTerm settings, the status line, MCP scope, codex workers, updating, uninstalling |
72
72
  | [Troubleshooting](docs/troubleshooting.md) | Common errors and their fixes |
73
+ | [Security](docs/security.md) | What hive runs, writes and fetches on your machine, with the code to check |
73
74
  | [Tools](docs/tools.md) | The MCP tools: what each does and when to use it |
74
75
  | [tmux settings](docs/tmux.md) | Attach modes, pane options, and what to put in `~/.tmux.conf` |
75
76
  | [Development](docs/development.md) | Building and testing hive itself |
@@ -1 +1 @@
1
- {"version":"1.11.0","sha":"7534ff6","dirty":false,"build_id":"bd31535e0eaed043ebe60b90c2e95925e21567baf82b5142e3a6c0e6908a58ac"}
1
+ {"version":"1.11.2","sha":"6919b35","dirty":false,"build_id":"f1a0c66c3af90eb5af9a295a8b233d987cf4133f6355eea5cf46b87f5289e0bd"}
package/dist/cli.js CHANGED
@@ -78,7 +78,7 @@ Usage:
78
78
  snapshot; read-only, writes nothing
79
79
  hive portfolio [--json] one row per registered project: lane, lead, workers,
80
80
  todos, needs-human count, wakes; read-only
81
- hive queen-audit confirmed queen writes, newest first; 30-day history
81
+ hive queen-audit queen writes and refused calls, newest first; 30-day history
82
82
  [--project-id <id>] filter the target project (queen only across projects)
83
83
  [--limit <n>] [--json] default 20, maximum 100; JSON prints one object
84
84
  hive next [--print] attach to the lead of the project that needs you most
@@ -590,9 +590,10 @@ async function cmdLead(argv) {
590
590
  try {
591
591
  wonRace = db.transaction(() => {
592
592
  const updated = db
593
- .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? " +
593
+ .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ?, " +
594
+ "pane_started_at = CASE WHEN ? = 1 THEN strftime('%Y-%m-%d %H:%M:%f', 'now') ELSE pane_started_at END " +
594
595
  "WHERE id = ? AND tmux_target = ? AND tmux_socket = ? AND pane_pid = ? AND status = 'running'")
595
- .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), recordedPanePid, recordedCommand, recordedCodexHome, leadAgentId, casExpected, casSocket, casPid).changes;
596
+ .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), recordedPanePid, recordedCommand, recordedCodexHome, createdPane ? 1 : 0, leadAgentId, casExpected, casSocket, casPid).changes;
596
597
  if (updated === 0)
597
598
  return false;
598
599
  db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
@@ -2857,19 +2858,20 @@ function canonicalPath(path) {
2857
2858
  function nextCandidates() {
2858
2859
  const queenRoot = canonicalPath(join(storeDir(), "queen"));
2859
2860
  const byCount = (a, b) => b - a;
2861
+ const waitsOnYou = (p) => p.needs_human + p.questions_pending;
2860
2862
  return collectPortfolio()
2861
- .projects.filter((p) => (p.lane === "waiting_on_you" || p.lane === "stuck" || (p.lane === "moving" && p.needs_human > 0)) &&
2863
+ .projects.filter((p) => (p.lane === "waiting_on_you" || p.lane === "stuck" || (p.lane === "moving" && waitsOnYou(p) > 0)) &&
2862
2864
  canonicalPath(p.root) !== queenRoot)
2863
2865
  .sort((a, b) => {
2864
- const tier = (p) => (p.lane === "waiting_on_you" ? 0 : p.needs_human > 0 ? 1 : 2);
2866
+ const tier = (p) => (p.lane === "waiting_on_you" ? 0 : waitsOnYou(p) > 0 ? 1 : 2);
2865
2867
  if (tier(a) !== tier(b))
2866
2868
  return tier(a) - tier(b);
2867
2869
  if (a.lane === "waiting_on_you") {
2868
- return (byCount(a.needs_human, b.needs_human) ||
2870
+ return (byCount(waitsOnYou(a), waitsOnYou(b)) ||
2869
2871
  a.last_activity_at.localeCompare(b.last_activity_at) ||
2870
2872
  a.id - b.id);
2871
2873
  }
2872
- return (byCount(a.needs_human, b.needs_human) ||
2874
+ return (byCount(waitsOnYou(a), waitsOnYou(b)) ||
2873
2875
  byCount(a.todos.blocked_in_progress, b.todos.blocked_in_progress) ||
2874
2876
  byCount(a.wakes.overdue, b.wakes.overdue) ||
2875
2877
  byCount(a.workers.needs_input, b.workers.needs_input) ||
@@ -2948,7 +2950,7 @@ function cmdQueenAudit(argv) {
2948
2950
  return;
2949
2951
  }
2950
2952
  if (report.entries.length === 0) {
2951
- console.log("No queen actions recorded.");
2953
+ console.log("No queen audit entries recorded.");
2952
2954
  return;
2953
2955
  }
2954
2956
  for (const entry of report.entries) {
@@ -3371,6 +3373,9 @@ function cmdAnswer(argv) {
3371
3373
  if (receipt.wake_id === null) {
3372
3374
  console.log(`No wake queued: ${receipt.wake_skipped ?? "the asking session could not be reached"}.`);
3373
3375
  }
3376
+ else if (receipt.wake_rerouted_to) {
3377
+ console.log(`Wake #${receipt.wake_id} queued for ${receipt.wake_rerouted_to}; the asker is not running.`);
3378
+ }
3374
3379
  else {
3375
3380
  console.log(`Wake #${receipt.wake_id} queued for the asking session.`);
3376
3381
  }
package/dist/context.js CHANGED
@@ -5,6 +5,8 @@ import { basename, dirname, join, resolve, sep } from "node:path";
5
5
  import { storeDir } from "./dataDir.js";
6
6
  import { db } from "./db.js";
7
7
  import { LEAD_KIND } from "./spawn.js";
8
+ import { rowOwnership } from "./tmux.js";
9
+ import { recordQueenRefusal } from "./queenAudit.js";
8
10
  let selectedId = null;
9
11
  let cachedActorId = null;
10
12
  let lastTouchMs = 0;
@@ -294,6 +296,30 @@ export function captureQueenWriteIdentity(targetProjectId) {
294
296
  return null;
295
297
  return { actor_id: process.env.HIVE_AGENT_ID, home_project_id: home };
296
298
  }
299
+ export function resolveQueenLeadTarget(operation, targetProjectId, ref, resolve) {
300
+ if (!captureQueenWriteIdentity(targetProjectId))
301
+ return resolve();
302
+ let agent;
303
+ try {
304
+ agent = resolve();
305
+ }
306
+ catch (e) {
307
+ throw queenRefusal(operation, targetProjectId, `recipient ${JSON.stringify(ref)} could not be resolved: ${e instanceof Error ? e.message : String(e)}`);
308
+ }
309
+ const ownership = rowOwnership(agent);
310
+ const problem = agent.project_id !== targetProjectId
311
+ ? `Agent ${agent.id} ("${agent.name}") belongs to project ${agent.project_id}, not project ${targetProjectId}`
312
+ : agent.kind !== LEAD_KIND
313
+ ? `Agent ${agent.id} ("${agent.name}") is kind "${agent.kind}", not a lead`
314
+ : agent.status !== "running"
315
+ ? `Agent ${agent.id} ("${agent.name}") is ${agent.status}, not running`
316
+ : ownership !== "live"
317
+ ? `Agent ${agent.id} ("${agent.name}") does not own a live pane (ownership: ${ownership})`
318
+ : null;
319
+ if (problem)
320
+ throw queenRefusal(operation, targetProjectId, problem);
321
+ return agent;
322
+ }
297
323
  export const QUEEN_REACH = {
298
324
  whoami: "read",
299
325
  help: "read",
@@ -379,7 +405,7 @@ function foreignWriteProblem(reach, targetProjectId, args) {
379
405
  const lead = runningLead(targetProjectId);
380
406
  if (!lead)
381
407
  return "that project has no running lead to address";
382
- if (!namesLead(args.deliver_to, lead)) {
408
+ if (args.deliver_to == null) {
383
409
  return `deliver_to must name that project's running lead ("${lead.name}" or agent id ${lead.id})`;
384
410
  }
385
411
  return null;
@@ -402,8 +428,8 @@ function foreignWriteProblem(reach, targetProjectId, args) {
402
428
  const lead = runningLead(targetProjectId);
403
429
  if (!lead)
404
430
  return "that project has no running lead to address";
405
- if (!namesLead(args.agent_id ?? args.name, lead)) {
406
- return `the recipient must be that project's running lead ("${lead.name}" or agent id ${lead.id})`;
431
+ if (args.agent_id == null && args.name == null) {
432
+ return `the recipient must name that project's running lead ("${lead.name}" or agent id ${lead.id})`;
407
433
  }
408
434
  return null;
409
435
  }
@@ -446,8 +472,20 @@ export function assertQueenToolCall(tool, args) {
446
472
  function queenRefusal(operation, targetProjectId, problem) {
447
473
  const target = targetProjectId === null ? null : getProject(targetProjectId);
448
474
  const where = target ? `in project ${target.id} ("${target.name}")` : "outside the queen's own project";
449
- return new Error(`${QUEEN_REFUSAL}: the queen cannot run ${operation} ${where}: ${problem}. Into another project the ` +
475
+ const refusal = new Error(`${QUEEN_REFUSAL}: the queen cannot run ${operation} ${where}: ${problem}. Into another project the ` +
450
476
  "queen may only todo_create, todo_comment, send text or wakes to its running lead, start its lead " +
451
477
  "with `hive lead <path>`, and run project_prune or `hive project rm` on it. Everything else there is its lead's to do.");
478
+ if (targetProjectId !== null) {
479
+ const identity = captureQueenWriteIdentity(targetProjectId);
480
+ if (identity) {
481
+ try {
482
+ recordQueenRefusal(operation, targetProjectId, problem, identity);
483
+ }
484
+ catch {
485
+ // A refusal must survive an unavailable audit write.
486
+ }
487
+ }
488
+ }
489
+ return refusal;
452
490
  }
453
491
  export const HUMAN_ACTOR_PREFIX = "user:";
package/dist/dashboard.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { createRequire } from "node:module";
2
3
  import { db } from "./db.js";
3
4
  import { getProject } from "./context.js";
4
5
  export const describeVisibility = (visibility) => visibility === null ? "running" : visibility === "window" ? "own window" : visibility;
@@ -12,6 +13,7 @@ export function processCounts(procs) {
12
13
  }
13
14
  import { awaitingFirstPrompt } from "./firstPrompt.js";
14
15
  import { SLUG_MAX_LEN, cutToUnitBudget } from "./slug.js";
16
+ const requireFromHere = createRequire(import.meta.url);
15
17
  export const TODO_CAP = 100;
16
18
  export const AGENT_CAP = 50;
17
19
  export const WAKE_CAP = 50;
@@ -449,6 +451,45 @@ function renderWakesSection(index, projectId) {
449
451
  const body = renderCappedList(shown.length, all.length, "pending wakes", "wakes", rows, emptyState("No pending wakes.", "Scheduled and idle-triggered check-ins from wake_set show up here."));
450
452
  return section(index, "wakes", "Wakes", `${all.length} pending`, "bell", String(shown.length), body);
451
453
  }
454
+ function questionAge(createdAt) {
455
+ const timestamp = Date.parse(`${createdAt.replace(" ", "T")}Z`);
456
+ if (!Number.isFinite(timestamp))
457
+ return "unknown";
458
+ const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
459
+ if (seconds >= 86400)
460
+ return `${Math.floor(seconds / 86400)}d ago`;
461
+ if (seconds >= 3600)
462
+ return `${Math.floor(seconds / 3600)}h ago`;
463
+ if (seconds >= 60)
464
+ return `${Math.floor(seconds / 60)}m ago`;
465
+ return "now";
466
+ }
467
+ function renderQuestionsSection(index, projectId) {
468
+ // A static import of questions.js cycles through tools/wakes.ts and scheduler.ts.
469
+ const { listQuestions, pendingQuestionCount } = requireFromHere("./questions.js");
470
+ const questions = listQuestions(projectId);
471
+ if (questions.length === 0)
472
+ return "";
473
+ const pendingCount = pendingQuestionCount(projectId);
474
+ const rows = questions.map((question) => {
475
+ const options = question.kind === "free_text"
476
+ ? '<p class="question-options">free text</p>'
477
+ : `<ul class="question-options">${(question.options ?? []).map((option) => `<li>${prose(option.label)}${option.recommended ? ' <span class="recommended">recommended</span>' : ""}` +
478
+ `<span class="question-option-description">${prose(option.description)}</span></li>`).join("")}</ul>`;
479
+ const ref = question.ref ? `<span class="question-ref">${question.ref.kind === "todo" ? "todo" : "PR"} #${question.ref.number}</span>` : "";
480
+ const state = question.state === "defaulted"
481
+ ? '<span class="question-state">defaulted - proceeding on the default</span>'
482
+ : '<span class="question-state">pending</span>';
483
+ return (`<li class="question" data-question="${question.id}"><div class="question-head">${mono(`#${question.id}`)} ` +
484
+ `<strong>${escapeHtml(question.header)}</strong> ${state}</div>` +
485
+ `<p class="question-text">${prose(question.question)}</p>${options}` +
486
+ `<p class="question-default">Default answer: ${prose(question.default_answer)}</p>` +
487
+ `<p class="question-meta">Asked by ${mono(question.asker)} · ${escapeHtml(questionAge(question.created_at))}` +
488
+ `${ref ? ` · ${ref}` : ""}</p>` +
489
+ `<p class="question-answer">To answer: <code>hive answer ${question.id}</code></p></li>`);
490
+ }).join("\n");
491
+ return section(index, "questions", "Questions", `${pendingCount} pending`, "inbox", String(pendingCount), `<ul class="question-list">${rows}</ul>`);
492
+ }
452
493
  function fetchCommentActivity(projectId) {
453
494
  const rows = db
454
495
  .prepare(`SELECT c.id, c.body, c.author, c.created_at, t.id AS todo_id, t.title AS todo_title,
@@ -682,6 +723,15 @@ export const SECTION_STYLE = `
682
723
  .priority-low { color: var(--fg-subtle); }
683
724
  .blockers { font-size: 0.8125rem; color: var(--fg-muted); margin: 0 0 0.45rem 0.6rem; }
684
725
  .wake-body { color: var(--fg); font-size: 0.875rem; overflow-wrap: anywhere; }
726
+ .question-list { display: grid; gap: 0.85rem; list-style: none; margin: 0; padding: 0; }
727
+ .question { border-bottom: 1px solid var(--border); padding-bottom: 0.85rem; overflow-wrap: anywhere; }
728
+ .question:last-child { border-bottom: 0; padding-bottom: 0; }
729
+ .question-head, .question-meta, .question-answer, .question-default, .question-text, .question-options { margin: 0.2rem 0; }
730
+ .question-head, .question-meta, .question-answer { font-size: 0.8125rem; }
731
+ .question-state, .question-ref, .recommended { color: var(--fg-muted); font-size: 0.75rem; }
732
+ .question-options { padding-left: 1.2rem; }
733
+ .question-option-description { display: block; padding-left: 0.5rem; color: var(--fg-muted); }
734
+ .question-text { overflow-wrap: anywhere; white-space: pre-wrap; }
685
735
  .activity-body { font-size: 0.8125rem; color: var(--fg-muted); white-space: pre-wrap;
686
736
  overflow-wrap: anywhere; margin-top: 0.1rem; }
687
737
 
@@ -1171,7 +1221,9 @@ function buildDashboard(projectId, procs) {
1171
1221
  const project = fetchProject(projectId);
1172
1222
  const stats = fetchDayStats(projectId);
1173
1223
  const index = [];
1224
+ const questionsSection = renderQuestionsSection(index, projectId);
1174
1225
  const cards = [
1226
+ ...(questionsSection ? [questionsSection] : []),
1175
1227
  renderThroughputSection(index, stats),
1176
1228
  renderBoardSection(index, projectId),
1177
1229
  renderTodosSection(index, projectId),
package/dist/db.js CHANGED
@@ -568,6 +568,14 @@ CREATE TABLE questions (
568
568
  CREATE UNIQUE INDEX idx_questions_pending_ref ON questions(project_id, ref_kind, ref_number)
569
569
  WHERE state = 'pending' AND ref_kind IS NOT NULL;
570
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
+ `,
577
+ `
578
+ ALTER TABLE agents ADD COLUMN pane_started_at TEXT;
571
579
  `,
572
580
  ];
573
581
  export function storeSchemaAhead(database) {
package/dist/portfolio.js CHANGED
@@ -8,6 +8,7 @@ import { leadSessionEnded, readLeadTurnState } from "./leadState.js";
8
8
  import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
9
9
  export const PORTFOLIO_REASONS = [
10
10
  "needs_human",
11
+ "questions_pending",
11
12
  "dead_lead_pane",
12
13
  "missing_root_with_work",
13
14
  "worker_needs_input",
@@ -136,9 +137,12 @@ function projectRow(project, now, snapshot) {
136
137
  const staleClock = lastActivity(project.id, false);
137
138
  const staleBefore = shiftedClock(now, STALE_IN_PROGRESS);
138
139
  const activeTodos = todos.open + todos.in_progress;
140
+ const questionsPending = pendingQuestionCount(project.id);
139
141
  const found = new Set();
140
142
  if (tagged.length > 0)
141
143
  found.add("needs_human");
144
+ if (questionsPending > 0)
145
+ found.add("questions_pending");
142
146
  if ((lead.state === "dead_pane" || lead.state === "dormant") && (todos.in_progress > 0 || wakeCounts.pending > 0))
143
147
  found.add("dead_lead_pane");
144
148
  if (!rootExists && (activeTodos > 0 || wakeCounts.pending > 0))
@@ -173,7 +177,7 @@ function projectRow(project, now, snapshot) {
173
177
  lane = "stuck";
174
178
  else if (movingSignal)
175
179
  lane = "moving";
176
- else if (found.has("needs_human"))
180
+ else if (found.has("needs_human") || found.has("questions_pending"))
177
181
  lane = "waiting_on_you";
178
182
  else if (TODO_GRAPH.some((r) => found.has(r)))
179
183
  lane = "stuck";
@@ -192,7 +196,7 @@ function projectRow(project, now, snapshot) {
192
196
  workers,
193
197
  todos,
194
198
  needs_human: tagged.length,
195
- questions_pending: pendingQuestionCount(project.id),
199
+ questions_pending: questionsPending,
196
200
  needs_human_items: tagged.slice(0, NEEDS_HUMAN_ITEM_CAP).map((t) => ({
197
201
  todo_id: t.id,
198
202
  title: t.title,
@@ -13,6 +13,11 @@ function removalSummary(r) {
13
13
  const counts = Object.entries((r.counts ?? {})).map(([t, n]) => `${t} ${n}`);
14
14
  return `removed project "${r.name}"${counts.length > 0 ? ` with ${counts.join(", ")}` : ""}`;
15
15
  }
16
+ function insertQueenAuditRow(operation, targetProjectId, identity, resourceType, resourceId, summary, outcome = "confirmed", problem = "") {
17
+ db.prepare(`INSERT INTO queen_audit
18
+ (actor_id, home_project_id, target_project_id, operation, resource_type, resource_id, summary, outcome, problem)
19
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(identity.actor_id, identity.home_project_id, targetProjectId, operation, resourceType, resourceId, summary, outcome, problem);
20
+ }
16
21
  export const QUEEN_AUDIT_OPERATIONS = {
17
22
  todo_create: {
18
23
  mode: "database",
@@ -78,8 +83,15 @@ function insertAudit(operation, targetProjectId, args, receipt, identity) {
78
83
  return;
79
84
  const text = flatten(spec.summary(args, receipt));
80
85
  const summary = text.length <= QUEEN_AUDIT_SUMMARY_MAX ? text : cutToUnitBudget(text, QUEEN_AUDIT_SUMMARY_MAX - 1) + "…";
81
- db.prepare(`INSERT INTO queen_audit (actor_id, home_project_id, target_project_id, operation, resource_type, resource_id, summary)
82
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(identity.actor_id, identity.home_project_id, targetProjectId, operation, touched.type, touched.id, summary);
86
+ insertQueenAuditRow(operation, targetProjectId, identity, touched.type, touched.id, summary);
87
+ }
88
+ export function recordQueenRefusal(operation, targetProjectId, problem, identity) {
89
+ const text = flatten(problem);
90
+ const summaryText = `refused: ${text}`;
91
+ const summary = summaryText.length <= QUEEN_AUDIT_SUMMARY_MAX
92
+ ? summaryText
93
+ : cutToUnitBudget(summaryText, QUEEN_AUDIT_SUMMARY_MAX - 1) + "…";
94
+ insertQueenAuditRow(operation, targetProjectId, identity, "project", targetProjectId, summary, "refused", text);
83
95
  }
84
96
  export function commitQueenStateWrite(operation, targetProjectId, args, mutate) {
85
97
  const identity = captureQueenWriteIdentity(targetProjectId);
@@ -84,6 +84,7 @@ const LANE_COLOR = {
84
84
  };
85
85
  const REASON_LABEL = {
86
86
  needs_human: "Needs-human todo",
87
+ questions_pending: "Pending question",
87
88
  dead_lead_pane: "Lead pane is dead",
88
89
  missing_root_with_work: "Project folder is missing",
89
90
  worker_needs_input: "A worker is waiting for input",
@@ -210,10 +211,12 @@ function footCounts(p, asOf, rank) {
210
211
  (rank ? `<span class="age">seen ${esc(age(p.last_activity_at, asOf))}</span>` : ""));
211
212
  }
212
213
  function movingBadges(p) {
213
- if (p.lane !== "moving")
214
- return "";
215
- return ((p.needs_human > 0 ? `<span class="n-needs">${p.needs_human} need you</span>` : "") +
216
- (p.todos.blocked_in_progress > 0 ? `<span class="n-muted">${p.todos.blocked_in_progress} blocked</span>` : ""));
214
+ const questions = p.questions_pending > 0
215
+ ? `<span class="n-questions">${p.questions_pending} question${p.questions_pending === 1 ? "" : "s"}</span>`
216
+ : "";
217
+ return ((p.lane === "moving" && p.needs_human > 0 ? `<span class="n-needs">${p.needs_human} need you</span>` : "") +
218
+ questions +
219
+ (p.lane === "moving" && p.todos.blocked_in_progress > 0 ? `<span class="n-muted">${p.todos.blocked_in_progress} blocked</span>` : ""));
217
220
  }
218
221
  function card(p, report, rank, href) {
219
222
  const asOf = report.as_of;
@@ -226,7 +229,7 @@ function card(p, report, rank, href) {
226
229
  items.push(`<li><span class="ref">+${p.needs_human - p.needs_human_items.length} more</span></li>`);
227
230
  }
228
231
  for (const r of p.reasons) {
229
- if (r === "needs_human" || r === "quiet")
232
+ if (r === "needs_human" || r === "questions_pending" || r === "quiet")
230
233
  continue;
231
234
  const cls = REASON_SEVERE.has(r) ? " age-fail" : "";
232
235
  items.push(`<li><span class="${cls.trim()}">${esc(REASON_LABEL[r])}</span></li>`);
@@ -263,19 +266,22 @@ function gridRow(p, asOf, href) {
263
266
  `<span class="c-work">${workersCell(p)}</span>` +
264
267
  `<span class="c-n g-num">${num(t.open)}</span><span class="c-n g-num">${num(t.in_progress)}</span>` +
265
268
  `<span class="c-n g-num">${num(t.blocked, "n-blocked")}</span><span class="c-n g-num">${num(t.high, "n-high")}</span>` +
266
- `<span class="c-n g-num">${num(p.needs_human, "n-needs")}</span><span class="c-n g-num c-wake">${wakesCell(p)}</span>` +
269
+ `<span class="c-n g-num">${num(p.needs_human, "n-needs")}</span>` +
270
+ `<span class="c-n g-num">${num(p.questions_pending, "n-questions")}</span>` +
271
+ `<span class="c-n g-num c-wake">${wakesCell(p)}</span>` +
267
272
  `<span class="c-last g-num age">${esc(age(p.last_activity_at, asOf))}</span>` +
268
273
  '<span class="c-nums">' +
269
274
  `<span>${t.open} open</span><span>${t.in_progress} doing</span>` +
270
275
  (t.blocked ? `<span class="n-blocked">${t.blocked} blocked</span>` : "") +
271
276
  (t.high ? `<span class="n-high">${t.high} high</span>` : "") +
272
277
  (p.needs_human ? `<span class="n-needs">${p.needs_human} need you</span>` : "") +
278
+ (p.questions_pending ? `<span class="n-questions">${p.questions_pending} question${p.questions_pending === 1 ? "" : "s"}</span>` : "") +
273
279
  wakesText(p) +
274
280
  "</span></div>");
275
281
  }
276
282
  const GRID_HEAD = '<div class="g-row g-head" role="row"><span>Project</span><span>Lead</span><span>Workers</span>' +
277
283
  '<span class="g-num">Open</span><span class="g-num">Doing</span><span class="g-num">Blocked</span>' +
278
- '<span class="g-num">High</span><span class="g-num">Needs you</span><span class="g-num c-wake">Wakes</span>' +
284
+ '<span class="g-num">High</span><span class="g-num">Needs you</span><span class="g-num">Questions</span><span class="g-num c-wake">Wakes</span>' +
279
285
  '<span class="g-num">Last seen</span></div>';
280
286
  function laneName(lane) {
281
287
  return LANE_TITLE[lane].toLowerCase();
@@ -409,7 +415,7 @@ a.proj:hover { color: var(--accent); text-decoration: underline; text-underline-
409
415
  .drift { margin: 0.5rem 0 0; grid-column: 1 / -1; padding: 0.55rem 0.75rem; border-radius: var(--r-ctl); background: var(--warn-bg); color: var(--fg); font-size: 0.8125rem; }
410
416
  .drift strong { color: var(--warn); font-weight: 650; }
411
417
  .grid { display: grid; }
412
- .g-row { display: grid; grid-template-columns: minmax(8.5rem, 1.3fr) 10.5rem minmax(8rem, 1.2fr) repeat(4, 3.2rem) 4.6rem 5.2rem 5.6rem; align-items: center; gap: 0.6rem; padding: 0.5rem 1.1rem; border-top: 1px solid var(--border); font-size: 0.875rem; }
418
+ .g-row { display: grid; grid-template-columns: minmax(8.5rem, 1.3fr) 10.5rem minmax(8rem, 1.2fr) repeat(4, 3.2rem) 4.6rem 4.6rem 5.2rem 5.6rem; align-items: center; gap: 0.6rem; padding: 0.5rem 1.1rem; border-top: 1px solid var(--border); font-size: 0.875rem; }
413
419
  .g-row:hover:not(.g-head) { background: var(--hover); }
414
420
  .g-head { font-size: 0.75rem; color: var(--fg-subtle); font-weight: 550; border-top: 0; padding-block: 0.2rem 0.4rem; }
415
421
  .g-num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
package/dist/questions.js CHANGED
@@ -94,6 +94,12 @@ function runningAgentOf(projectId, actor) {
94
94
  .get(projectId, actor);
95
95
  return row?.id ?? null;
96
96
  }
97
+ function runningLeadOf(projectId) {
98
+ const row = db
99
+ .prepare("SELECT id, actor_id FROM agents WHERE project_id = ? AND kind = 'lead' AND status = 'running' ORDER BY id DESC LIMIT 1")
100
+ .get(projectId);
101
+ return row ?? null;
102
+ }
97
103
  const optionsJson = (input) => (input.kind === "choice" ? JSON.stringify(input.options) : null);
98
104
  function pendingIdForRef(projectId, ref) {
99
105
  const row = db
@@ -214,8 +220,19 @@ function planAnswerWake(projectId, row, actor) {
214
220
  if (actor === row.asker)
215
221
  return { skipped: "answered by the asker" };
216
222
  const agentId = runningAgentOf(projectId, row.asker);
217
- if (agentId === null)
218
- return { skipped: `the asker ${row.asker} has no running agent` };
223
+ if (agentId === null) {
224
+ const lead = runningLeadOf(projectId);
225
+ if (lead === null)
226
+ return { skipped: `the asker ${row.asker} has no running agent` };
227
+ if (lead.actor_id === actor)
228
+ return { skipped: "answered by the lead that would receive it" };
229
+ try {
230
+ return { delivery: resolveWakeDelivery(projectId, lead.id), reroutedFrom: row.asker };
231
+ }
232
+ catch (e) {
233
+ return { skipped: e instanceof Error ? e.message : String(e) };
234
+ }
235
+ }
219
236
  try {
220
237
  return { delivery: resolveWakeDelivery(projectId, agentId) };
221
238
  }
@@ -232,11 +249,19 @@ const commitAnswer = db.transaction((projectId, actor, id, answer, notes, plan)
232
249
  updated_at = datetime('now') WHERE id = ?`).run(answer, notes, actor, actor, id);
233
250
  if ("skipped" in plan)
234
251
  return { question_id: id, wake_id: null, wake_skipped: plan.skipped };
252
+ const body = answerWakeBody(id, row.question, answer, notes);
253
+ const wakeBody = "reroutedFrom" in plan && plan.reroutedFrom !== undefined
254
+ ? `${body}\nAsked by ${plan.reroutedFrom}, which is not running; delivered to this project's lead.`
255
+ : body;
235
256
  const wake = db
236
257
  .prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at)
237
258
  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 };
259
+ .get(projectId, actor, wakeBody, plan.delivery.actor, plan.delivery.pane);
260
+ return {
261
+ question_id: id,
262
+ wake_id: wake.id,
263
+ ...("reroutedFrom" in plan && plan.reroutedFrom !== undefined ? { wake_rerouted_to: plan.delivery.actor } : {}),
264
+ };
240
265
  });
241
266
  export function answerQuestion(projectId, actor, id, answer, notes) {
242
267
  if (blank(answer))
package/dist/scheduler.js CHANGED
@@ -600,6 +600,8 @@ function holdTimer(timer, reason) {
600
600
  OR (${socketAwaitingWhere()}))`, reason, timer.id, timer.due_at, count, count);
601
601
  }
602
602
  const HELD_REASON_MODAL_CHOICE = "pane is awaiting a modal choice (folder-trust or /model picker)";
603
+ const LEAD_BOOT_BOX_WAIT_MS = 60_000;
604
+ const HELD_REASON_LEAD_BOOT_INPUT_BOX = "the lead's input box is not on screen yet; holding while its session starts";
603
605
  export const HELD_REASON_LEAD_PANE_DEAD = "the lead's pane is not live right now (likely mid-restart); lead-owned wakes are exempt from " +
604
606
  "cancellation for this alone, so it is held rather than lost";
605
607
  const HELD_REASON_PANE_REISSUED_PREFIX = "the pane id recorded for this wake now belongs to a different pane than the one it was set " +
@@ -664,6 +666,25 @@ export function isUnsubmittedInputHold(heldReason) {
664
666
  export const HELD_REASON_CONVERSATION = "a human talked to this lead more recently than the conversation-hold window; holding so a wake " +
665
667
  "does not split an in-progress discussion - it delivers once the window passes or the hold's own " +
666
668
  "ceiling is reached, whichever comes first";
669
+ function leadBootSessionIsFresh(agentId, panePid) {
670
+ const row = stmt("SELECT pane_started_at FROM agents WHERE id = ? AND pane_pid = ?").get(agentId, panePid);
671
+ if (!row?.pane_started_at)
672
+ return false;
673
+ const timestamp = Date.parse(`${row.pane_started_at.replace(" ", "T")}Z`);
674
+ return Number.isFinite(timestamp) && Date.now() - timestamp < LEAD_BOOT_BOX_WAIT_MS;
675
+ }
676
+ function leadInputBoxObservation(pane, command, cache) {
677
+ const entry = cacheEntry(pane, cache);
678
+ if (entry.snapshot === undefined) {
679
+ entry.snapshot = paneClassifierFor(command)?.inputBoxSnapshot?.(pane) ?? null;
680
+ }
681
+ if (entry.snapshot === null) {
682
+ entry.box = null;
683
+ return { captured: false, box: null };
684
+ }
685
+ entry.box = entry.snapshot.box;
686
+ return { captured: true, box: entry.snapshot.box };
687
+ }
667
688
  // A human message keeps a lead-bound wake held for this long after it lands. Measured deferral rates
668
689
  // for this and CONVERSATION_HOLD_MAX are in hive-internals/references/worker-state.md.
669
690
  const CONVERSATION_HOLD_TTL_SECONDS = 5 * 60;
@@ -1557,7 +1578,26 @@ function deliverable(timer, snapshot, choices) {
1557
1578
  noteModalHold(timer, snapshot);
1558
1579
  return { ok: false };
1559
1580
  }
1560
- if (inputBoxHoldsWake(timer.deliver_pane, timer.deliver_command, choices)) {
1581
+ const claudeLeadSnapshot = isLeadActorId(timer.deliver_actor) && paneClassifierFor(timer.deliver_command)?.inputBoxSnapshot !== undefined;
1582
+ if (claudeLeadSnapshot) {
1583
+ const bootWindow = row.deliver_row_id !== null && leadBootSessionIsFresh(row.deliver_row_id, row.deliver_pane_pid);
1584
+ const observation = leadInputBoxObservation(timer.deliver_pane, timer.deliver_command, choices);
1585
+ if (!observation.captured) {
1586
+ if (bootWindow)
1587
+ return { ok: false };
1588
+ }
1589
+ else {
1590
+ if (holdsHumanInput(observation.box)) {
1591
+ noteUnsubmittedInputHold(timer, snapshot);
1592
+ return { ok: false };
1593
+ }
1594
+ if (observation.box === null && bootWindow) {
1595
+ holdTimer(timer, HELD_REASON_LEAD_BOOT_INPUT_BOX);
1596
+ return { ok: false };
1597
+ }
1598
+ }
1599
+ }
1600
+ else if (inputBoxHoldsWake(timer.deliver_pane, timer.deliver_command, choices)) {
1561
1601
  noteUnsubmittedInputHold(timer, snapshot);
1562
1602
  return { ok: false };
1563
1603
  }
@@ -4,6 +4,19 @@ import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { DEFAULT_DATA_DIR } from "./dataDir.js";
6
6
  export const STALE_SERVER_BOUNDARY_SLACK_MS = 3000;
7
+ export function readProcessEnvironment(pid) {
8
+ try {
9
+ return execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], { encoding: "utf8" });
10
+ }
11
+ catch (error) {
12
+ if (typeof error === "object" && error !== null && "status" in error && error.status === 1 &&
13
+ "stdout" in error && String(error.stdout ?? "").trim() === "" &&
14
+ "stderr" in error && (String(error.stderr ?? "").trim() === "" || /no such process|process id .* (?:not found|does not exist)/i.test(String(error.stderr)))) {
15
+ return null;
16
+ }
17
+ throw error;
18
+ }
19
+ }
7
20
  const serverEntrypoint = resolve(dirname(fileURLToPath(import.meta.url)), "index.js");
8
21
  export function elapsedSeconds(value) {
9
22
  const dayForm = /^(\d+)-(\d{1,2}):(\d{2}):(\d{2})$/.exec(value);
@@ -94,7 +107,7 @@ export function parseStaleHiveServers(psOutput, appliedAt, observedAt = Date.now
94
107
  }));
95
108
  return { servers };
96
109
  }
97
- export function staleHiveServers(appliedAt, dataDir = DEFAULT_DATA_DIR) {
110
+ export function staleHiveServers(appliedAt, dataDir = DEFAULT_DATA_DIR, readEnvironment = readProcessEnvironment) {
98
111
  if (appliedAt === null)
99
112
  return { skipped: "the newest migration time is unavailable" };
100
113
  try {
@@ -104,9 +117,9 @@ export function staleHiveServers(appliedAt, dataDir = DEFAULT_DATA_DIR) {
104
117
  return parsed;
105
118
  const servers = [];
106
119
  for (const server of parsed.servers) {
107
- const envOutput = execFileSync("ps", ["eww", "-p", String(server.pid), "-o", "command="], {
108
- encoding: "utf8",
109
- });
120
+ const envOutput = readEnvironment(server.pid);
121
+ if (envOutput === null)
122
+ continue;
110
123
  const sameStore = processUsesDataDir(envOutput, dataDir);
111
124
  if (sameStore === null)
112
125
  return { skipped: "ps returned environment output that could not be parsed" };
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { db } from "../db.js";
5
5
  import { confirmQueenWrite } from "../queenAudit.js";
6
- import { captureQueenWriteIdentity } from "../context.js";
6
+ import { captureQueenWriteIdentity, resolveQueenLeadTarget } from "../context.js";
7
7
  import { agentBriefPath, mergedBriefVars, readAgentBrief, workerBrief, workerCommandString, writeAgentBrief, } from "../brief.js";
8
8
  import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile, refreshCodexMcpEnv } from "../codexHome.js";
9
9
  import { currentActor, findProjectForDir, getProject, linkedWorktreePrimaryRoot, resolveProject } from "../context.js";
@@ -1171,7 +1171,7 @@ export function registerAgents(server) {
1171
1171
  },
1172
1172
  }, (args) => run(async () => {
1173
1173
  const project = resolveProject(args.project_id);
1174
- const agent = findAgent(project.id, args);
1174
+ const agent = resolveQueenLeadTarget("agent_send", project.id, args.agent_id ?? args.name, () => findAgent(project.id, args));
1175
1175
  requireLive(agent);
1176
1176
  const target = agent.tmux_target;
1177
1177
  const auditIdentity = captureQueenWriteIdentity(project.id);
@@ -3,7 +3,7 @@ import { run } from "../result.js";
3
3
  import { idParam, limitParam } from "./params.js";
4
4
  export function registerQueenAudit(server) {
5
5
  server.registerTool("queen_audit_list", {
6
- description: "List confirmed queen writes into other projects, newest first. The queen may read all targets; other callers see only their own project. Default 20, maximum 100 entries. History is retained for 30 days with a 20,000 id-range backstop. A crash between a completed terminal send and its audit insert can leave that send unrecorded.",
6
+ description: "List confirmed queen writes and refused cross-project guard calls, newest first. Refusal rows include outcome=refused and the problem. The queen may read all targets; other callers see only their own project. Default 20, maximum 100 entries. History is retained for 30 days with a 20,000 id-range backstop. A crash between a completed terminal send and its audit insert can leave that send unrecorded.",
7
7
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
8
8
  inputSchema: {
9
9
  project_id: idParam.optional().describe("Filter by the target project id. Only the queen may name another project."),
@@ -71,7 +71,7 @@ export function registerQuestions(server) {
71
71
  return updateQuestion(effectiveProjectId(), currentActor(), question_id, patch);
72
72
  }));
73
73
  server.registerTool("question_answer", {
74
- description: "Record the human's answer to a pending or defaulted question in this project: a picked option label or free text (any nonblank text is accepted, including one outside the offered labels), plus optional notes. The answer commits first. It then queues one wake to the asking session carrying the question, answer and notes verbatim, unless you ARE the asker (you recorded the human's own pick) or the asker cannot be reached now; either way the receipt says wake_id null and why in wake_skipped, and the answer stays readable with question_get. wake_id means queued, not delivered. An answered or withdrawn question refuses with QUESTION_STATE_CONFLICT.",
74
+ description: "Record the human's answer to a pending or defaulted question in this project, plus optional notes. The answer may be a picked option label or free text, including text outside the offered labels. The answer update comes first; the answer and any wake commit together, and the answer stays readable with `question_get`. An answered or withdrawn question refuses with `QUESTION_STATE_CONFLICT`. A queued wake carries the question, answer and notes verbatim. When you are the asker, skip with `wake_id: null` and `wake_skipped`; otherwise, queue a wake to the running asker or, if the asker has no running row, to the project's running lead with `wake_rerouted_to`. Skip that fallback with `wake_id: null` and `wake_skipped` if no lead runs or the lead is the answerer. `wake_id` means queued, not delivered.",
75
75
  annotations: writes,
76
76
  inputSchema: {
77
77
  question_id: idParam,
@@ -82,6 +82,7 @@ export function registerQuestions(server) {
82
82
  question_id: idParam,
83
83
  wake_id: idParam.nullable(),
84
84
  wake_skipped: z.string().optional(),
85
+ wake_rerouted_to: z.string().optional(),
85
86
  },
86
87
  }, (args) => run(() => answerQuestion(effectiveProjectId(), currentActor(), args.question_id, args.answer, args.notes)));
87
88
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { db } from "../db.js";
3
3
  import { commitQueenStateWrite } from "../queenAudit.js";
4
- import { currentActor, effectiveProjectId, getProject, isQueenLead } from "../context.js";
4
+ import { captureQueenWriteIdentity, currentActor, effectiveProjectId, getProject, isQueenLead, resolveQueenLeadTarget, } from "../context.js";
5
5
  import { run } from "../result.js";
6
6
  import { findAgent, observeOwnership, notOwnedError, summaryLiveness } from "./agents.js";
7
7
  import { ACTIVE_TIMER_WHERE, LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
@@ -35,9 +35,11 @@ function refuseUnclassifiableTarget(agent) {
35
35
  "error you can read now. Drive this pane with agent_send(keys: [...]) instead, which is unguarded for " +
36
36
  "exactly this reason.");
37
37
  }
38
- export function resolveWakeDelivery(projectId, deliverTo) {
38
+ export function resolveWakeDelivery(projectId, deliverTo, queenOperation) {
39
39
  if (deliverTo != null) {
40
- const agent = resolveAgentRef(projectId, deliverTo);
40
+ const agent = queenOperation
41
+ ? resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => resolveAgentRef(projectId, deliverTo))
42
+ : resolveAgentRef(projectId, deliverTo);
41
43
  // Before the liveness probe: the harness is a durable fact about the row, while liveness is a
42
44
  // question about right now, so a dead bash worker should say what is actually wrong with it.
43
45
  refuseUnclassifiableTarget(agent);
@@ -52,18 +54,24 @@ export function resolveWakeDelivery(projectId, deliverTo) {
52
54
  const own = db
53
55
  .prepare("SELECT * FROM agents WHERE actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
54
56
  .get(actor);
57
+ if (queenOperation && !own && captureQueenWriteIdentity(projectId)) {
58
+ resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => {
59
+ throw new Error("the calling session has no running agent row to receive this wake");
60
+ });
61
+ }
55
62
  // Checked on the ROW, not behind the liveness probe: isLive is three-state, and letting a null
56
63
  // fall through to the TMUX_PANE branch below accepts a wake the scheduler will then hold forever
57
64
  // against that same row's command - the never-firing timer this refusal exists to prevent.
58
- if (own)
59
- refuseUnclassifiableTarget(own);
60
- if (own) {
61
- const ownership = observeOwnership(own);
65
+ const target = own && queenOperation ? resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => own) : own;
66
+ if (target)
67
+ refuseUnclassifiableTarget(target);
68
+ if (target) {
69
+ const ownership = observeOwnership(target);
62
70
  if (ownership === "live")
63
- return { actor, pane: own.tmux_target };
71
+ return { actor, pane: target.tmux_target };
64
72
  // Unknown never falls through to the ambient pane: that would bind this row's actor to a pane it may not own.
65
73
  if (ownership === "unknown")
66
- throw notOwnedError(own, ownership);
74
+ throw notOwnedError(target, ownership);
67
75
  }
68
76
  const pane = process.env.TMUX_PANE;
69
77
  if (pane)
@@ -331,7 +339,7 @@ export function registerWakes(server) {
331
339
  }, (args) => run(() => {
332
340
  assertSafeWakeBody(args.body);
333
341
  const projectId = effectiveProjectId(args.project_id);
334
- const delivery = resolveWakeDelivery(projectId, args.deliver_to);
342
+ const delivery = resolveWakeDelivery(projectId, args.deliver_to, "wake_set");
335
343
  return commitQueenStateWrite("wake_set", projectId, args, () => {
336
344
  const row = db
337
345
  .prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, repeat_every_ms)
@@ -441,7 +449,7 @@ export function registerWakes(server) {
441
449
  "reported as a timeout instead of the failure it actually is. A lead's turn has its own channel, " +
442
450
  "which only the queen may watch, with lead_project_id=<that lead's project id>.");
443
451
  }
444
- const delivery = resolveWakeDelivery(projectId, args.deliver_to);
452
+ const delivery = resolveWakeDelivery(projectId, args.deliver_to, "wake_when_idle");
445
453
  const snapshot = liveTargets();
446
454
  if (mode === "all") {
447
455
  const allIdle = watched.every((a) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "Run a crew of Claude Code and Codex workers from one lead session: visible tmux panes, exact state from hooks, one local SQLite store.",
5
5
  "type": "module",
6
6
  "bin": {