@cmgmyr/hive 1.11.0 → 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/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.1","sha":"59218ca","dirty":false,"build_id":"caa5cf415058468f8a1cd7152eac5af2255c5aed0e53f33e637bd75e3200fda0"}
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
@@ -2857,19 +2857,20 @@ function canonicalPath(path) {
2857
2857
  function nextCandidates() {
2858
2858
  const queenRoot = canonicalPath(join(storeDir(), "queen"));
2859
2859
  const byCount = (a, b) => b - a;
2860
+ const waitsOnYou = (p) => p.needs_human + p.questions_pending;
2860
2861
  return collectPortfolio()
2861
- .projects.filter((p) => (p.lane === "waiting_on_you" || p.lane === "stuck" || (p.lane === "moving" && p.needs_human > 0)) &&
2862
+ .projects.filter((p) => (p.lane === "waiting_on_you" || p.lane === "stuck" || (p.lane === "moving" && waitsOnYou(p) > 0)) &&
2862
2863
  canonicalPath(p.root) !== queenRoot)
2863
2864
  .sort((a, b) => {
2864
- const tier = (p) => (p.lane === "waiting_on_you" ? 0 : p.needs_human > 0 ? 1 : 2);
2865
+ const tier = (p) => (p.lane === "waiting_on_you" ? 0 : waitsOnYou(p) > 0 ? 1 : 2);
2865
2866
  if (tier(a) !== tier(b))
2866
2867
  return tier(a) - tier(b);
2867
2868
  if (a.lane === "waiting_on_you") {
2868
- return (byCount(a.needs_human, b.needs_human) ||
2869
+ return (byCount(waitsOnYou(a), waitsOnYou(b)) ||
2869
2870
  a.last_activity_at.localeCompare(b.last_activity_at) ||
2870
2871
  a.id - b.id);
2871
2872
  }
2872
- return (byCount(a.needs_human, b.needs_human) ||
2873
+ return (byCount(waitsOnYou(a), waitsOnYou(b)) ||
2873
2874
  byCount(a.todos.blocked_in_progress, b.todos.blocked_in_progress) ||
2874
2875
  byCount(a.wakes.overdue, b.wakes.overdue) ||
2875
2876
  byCount(a.workers.needs_input, b.workers.needs_input) ||
@@ -2948,7 +2949,7 @@ function cmdQueenAudit(argv) {
2948
2949
  return;
2949
2950
  }
2950
2951
  if (report.entries.length === 0) {
2951
- console.log("No queen actions recorded.");
2952
+ console.log("No queen audit entries recorded.");
2952
2953
  return;
2953
2954
  }
2954
2955
  for (const entry of report.entries) {
@@ -3371,6 +3372,9 @@ function cmdAnswer(argv) {
3371
3372
  if (receipt.wake_id === null) {
3372
3373
  console.log(`No wake queued: ${receipt.wake_skipped ?? "the asking session could not be reached"}.`);
3373
3374
  }
3375
+ else if (receipt.wake_rerouted_to) {
3376
+ console.log(`Wake #${receipt.wake_id} queued for ${receipt.wake_rerouted_to}; the asker is not running.`);
3377
+ }
3374
3378
  else {
3375
3379
  console.log(`Wake #${receipt.wake_id} queued for the asking session.`);
3376
3380
  }
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,11 @@ 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 '';
571
576
  `,
572
577
  ];
573
578
  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))
@@ -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.1",
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": {